Merge pull request #1 from jrperritt/master

gophercloud v2
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..4b317c2
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,21 @@
+language: go
+sudo: false
+install:
+- go get golang.org/x/crypto/ssh
+- go get -v -tags 'fixtures acceptance' ./...
+go:
+- 1.6
+- tip
+env:
+  global:
+  - secure: "xSQsAG5wlL9emjbCdxzz/hYQsSpJ/bABO1kkbwMSISVcJ3Nk0u4ywF+LS4bgeOnwPfmFvNTOqVDu3RwEvMeWXSI76t1piCPcObutb2faKLVD/hLoAS76gYX+Z8yGWGHrSB7Do5vTPj1ERe2UljdrnsSeOXzoDwFxYRaZLX4bBOB4AyoGvRniil5QXPATiA1tsWX1VMicj8a4F8X+xeESzjt1Q5Iy31e7vkptu71bhvXCaoo5QhYwT+pLR9dN0S1b7Ro0KVvkRefmr1lUOSYd2e74h6Lc34tC1h3uYZCS4h47t7v5cOXvMNxinEj2C51RvbjvZI1RLVdkuAEJD1Iz4+Ote46nXbZ//6XRZMZz/YxQ13l7ux1PFjgEB6HAapmF5Xd8PRsgeTU9LRJxpiTJ3P5QJ3leS1va8qnziM5kYipj/Rn+V8g2ad/rgkRox9LSiR9VYZD2Pe45YCb1mTKSl2aIJnV7nkOqsShY5LNB4JZSg7xIffA+9YVDktw8dJlATjZqt7WvJJ49g6A61mIUV4C15q2JPGKTkZzDiG81NtmS7hFa7k0yaE2ELgYocbcuyUcAahhxntYTC0i23nJmEHVNiZmBO3u7EgpWe4KGVfumU+lt12tIn5b3dZRBBUk3QakKKozSK1QPHGpk/AZGrhu7H6l8to6IICKWtDcyMPQ="
+before_install:
+- go get github.com/axw/gocov/gocov
+- go get github.com/mattn/goveralls
+- go get github.com/pierrre/gotestcover
+- if ! go get github.com/golang/tools/cmd/cover; then go get golang.org/x/tools/cmd/cover;
+  fi
+script:
+- $HOME/gopath/bin/gotestcover -v -tags=fixtures -coverprofile=cover.out ./...
+after_success:
+- $HOME/gopath/bin/goveralls -service=travis-ci -coverprofile=cover.out
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..becaf44
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,276 @@
+# Contributing to gophercloud
+
+- [Getting started](#getting-started)
+- [Tests](#tests)
+- [Style guide](#basic-style-guide)
+- [5 ways to get involved](#5-ways-to-get-involved)
+
+## Setting up your git workspace
+
+As a contributor you will need to setup your workspace in a slightly different
+way than just downloading it. Here are the basic installation instructions:
+
+1. Configure your `$GOPATH` and run `go get` as described in the main
+[README](/README.md#how-to-install).
+
+2. Move into the directory that houses your local repository:
+
+   ```bash
+   cd ${GOPATH}/src/github.com/gophercloud/gophercloud
+   ```
+
+3. Fork the `gophercloud/gophercloud` repository and update your remote refs. You
+will need to rename the `origin` remote branch to `upstream`, and add your
+fork as `origin` instead:
+
+   ```bash
+   git remote rename origin upstream
+   git remote add origin git@github.com/<my_username>/gophercloud
+   ```
+
+4. Checkout the latest development branch:
+
+   ```bash
+   git checkout master
+   ```
+
+5. If you're working on something (discussed more in detail below), you will
+need to checkout a new feature branch:
+
+   ```bash
+   git checkout -b my-new-feature
+   ```
+
+Another thing to bear in mind is that you will need to add a few extra
+environment variables for acceptance tests - this is documented in our
+[acceptance tests readme](/acceptance).
+
+## Tests
+
+When working on a new or existing feature, testing will be the backbone of your
+work since it helps uncover and prevent regressions in the codebase. There are
+two types of test we use in Gophercloud: unit tests and acceptance tests, which
+are both described below.
+
+### Unit tests
+
+Unit tests are the fine-grained tests that establish and ensure the behavior
+of individual units of functionality. We usually test on an
+operation-by-operation basis (an operation typically being an API action) with
+the use of mocking to set up explicit expectations. Each operation will set up
+its HTTP response expectation, and then test how the system responds when fed
+this controlled, pre-determined input.
+
+To make life easier, we've introduced a bunch of test helpers to simplify the
+process of testing expectations with assertions:
+
+```go
+import (
+  "testing"
+
+  "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestSomething(t *testing.T) {
+  result, err := Operation()
+
+  testhelper.AssertEquals(t, "foo", result.Bar)
+  testhelper.AssertNoErr(t, err)
+}
+
+func TestSomethingElse(t *testing.T) {
+  testhelper.CheckEquals(t, "expected", "actual")
+}
+```
+
+`AssertEquals` and `AssertNoErr` will throw a fatal error if a value does not
+match an expected value or if an error has been declared, respectively. You can
+also use `CheckEquals` and `CheckNoErr` for the same purpose; the only difference
+being that `t.Errorf` is raised rather than `t.Fatalf`.
+
+Here is a truncated example of mocked HTTP responses:
+
+```go
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGet(t *testing.T) {
+	// Setup the HTTP request multiplexer and server
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		// Test we're using the correct HTTP method
+		th.TestMethod(t, r, "GET")
+
+		// Test we're setting the auth token
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		// Set the appropriate headers for our mocked response
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		// Set the HTTP body
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "name": "private-network",
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+			`)
+	})
+
+	// Call our API operation
+	network, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+
+	// Assert no errors and equality
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Status, "ACTIVE")
+}
+```
+
+### Acceptance tests
+
+As we've already mentioned, unit tests have a very narrow and confined focus -
+they test small units of behavior. Acceptance tests on the other hand have a
+far larger scope: they are fully functional tests that test the entire API of a
+service in one fell swoop. They don't care about unit isolation or mocking
+expectations, they instead do a full run-through and consequently test how the
+entire system _integrates_ together. When an API satisfies expectations, it
+proves by default that the requirements for a contract have been met.
+
+Please be aware that acceptance tests will hit a live API - and may incur
+service charges from your provider. Although most tests handle their own
+teardown procedures, it is always worth manually checking that resources are
+deleted after the test suite finishes.
+
+### Running tests
+
+To run all tests:
+
+```bash
+go test ./...
+```
+
+To run all tests with verbose output:
+
+```bash
+go test -v ./...
+```
+
+To run tests that match certain [build tags]():
+
+```bash
+go test -tags "foo bar" ./...
+```
+
+To run tests for a particular sub-package:
+
+```bash
+cd ./path/to/package && go test .
+```
+
+## Style guide
+
+
+
+We follow the standard formatting recommendations and language idioms set out
+in the [Effective Go](https://golang.org/doc/effective_go.html) guide. It's
+definitely worth reading - but the relevant sections are
+[formatting](https://golang.org/doc/effective_go.html#formatting)
+and [names](https://golang.org/doc/effective_go.html#names).
+
+## 5 ways to get involved
+
+There are five main ways you can get involved in our open-source project, and
+each is described briefly below. Once you've made up your mind and decided on
+your fix, you will need to follow the same basic steps that all submissions are
+required to adhere to:
+
+1. [fork](https://help.github.com/articles/fork-a-repo/) the `gophercloud/gophercloud` repository
+2. checkout a [new branch](https://github.com/Kunena/Kunena-Forum/wiki/Create-a-new-branch-with-git-and-manage-branches)
+3. submit your branch as a [pull request](https://help.github.com/articles/creating-a-pull-request/)
+
+### 1. Providing feedback
+
+On of the easiest ways to get readily involved in our project is to let us know
+about your experiences using our SDK. Feedback like this is incredibly useful
+to us, because it allows us to refine and change features based on what our
+users want and expect of us. There are a bunch of ways to get in contact! You
+can [ping us](https://developer.rackspace.com/support/) via e-mail, talk to us on irc
+(#rackspace-dev on freenode), [tweet us](https://twitter.com/rackspace), or
+submit an issue on our [bug tracker](/issues). Things you might like to tell us
+are:
+
+* how easy was it to start using our SDK?
+* did it meet your expectations? If not, why not?
+* did our documentation help or hinder you?
+* what could we improve in general?
+
+### 2. Fixing bugs
+
+If you want to start fixing open bugs, we'd really appreciate that! Bug fixing
+is central to any project. The best way to get started is by heading to our
+[bug tracker](https://github.com/gophercloud/gophercloud/issues) and finding open
+bugs that you think nobody is working on. It might be useful to comment on the
+thread to see the current state of the issue and if anybody has made any
+breakthroughs on it so far.
+
+### 3. Improving documentation
+
+We have three forms of documentation:
+
+* short README documents that briefly introduce a topic
+* reference documentation on [godoc.org](http://godoc.org) that is automatically
+generated from source code comments
+* user documentation on our [homepage](http://gophercloud.io) that includes
+getting started guides, installation guides and code samples
+
+If you feel that a certain section could be improved - whether it's to clarify
+ambiguity, correct a technical mistake, or to fix a grammatical error - please
+feel entitled to do so! We welcome doc pull requests with the same childlike
+enthusiasm as any other contribution!
+
+### 4. Optimizing existing features
+
+If you would like to improve or optimize an existing feature, please be aware
+that we adhere to [semantic versioning](http://semver.org) - which means that
+we cannot introduce breaking changes to the API without a major version change
+(v1.x -> v2.x). Making that leap is a big step, so we encourage contributors to
+refactor rather than rewrite. Running tests will prevent regression and avoid
+the possibility of breaking somebody's current implementation.
+
+Another tip is to keep the focus of your work as small as possible - try not to
+introduce a change that affects lots and lots of files because it introduces
+added risk and increases the cognitive load on the reviewers checking your
+work. Change-sets which are easily understood and will not negatively impact
+users are more likely to be integrated quickly.
+
+Lastly, if you're seeking to optimize a particular operation, you should try to
+demonstrate a negative performance impact - perhaps using Go's inbuilt
+[benchmark capabilities](http://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go).
+
+### 5. Working on a new feature
+
+If you've found something we've left out, definitely feel free to start work on
+introducing that feature. It's always useful to open an issue or submit a pull
+request early on to indicate your intent to a core contributor - this enables
+quick/early feedback and can help steer you in the right direction by avoiding
+known issues. It might also help you avoid losing time implementing something
+that might not ever work. One tip is to prefix your Pull Request issue title
+with [wip] - then people know it's a work in progress.
+
+You must ensure that all of your work is well tested - both in terms of unit
+and acceptance tests. Untested code will not be merged because it introduces
+too much of a risk to end-users.
+
+Happy hacking!
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fbbbc9e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,191 @@
+Copyright 2012-2013 Rackspace, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License.  You may obtain a copy of the
+License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations under the License.                                
+
+------
+ 
+				Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
diff --git a/README.md b/README.md
index a3e0070..0532000 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,151 @@
-# Gophercloud
-A Go SDK for interacting with OpenStack
+# Gophercloud: an OpenStack SDK for Go
+[![Build Status](https://travis-ci.org/gophercloud/gophercloud.svg?branch=master)](https://travis-ci.org/gophercloud/gophercloud)
+[![Coverage Status](https://coveralls.io/repos/github/gophercloud/gophercloud/badge.svg?branch=master)](https://coveralls.io/github/gophercloud/gophercloud?branch=master)
+
+Gophercloud is an OpenStack Go SDK.
+
+## Useful links
+
+* [Reference documentation](http://godoc.org/github.com/gophercloud/gophercloud)
+* [Effective Go](https://golang.org/doc/effective_go.html)
+
+## How to install
+
+Before installing, you need to ensure that your [GOPATH environment variable](https://golang.org/doc/code.html#GOPATH)
+is pointing to an appropriate directory where you want to install Gophercloud:
+
+```bash
+mkdir $HOME/go
+export GOPATH=$HOME/go
+```
+
+To protect yourself against changes in your dependencies, we highly recommend choosing a
+[dependency management solution](https://github.com/golang/go/wiki/PackageManagementTools) for
+your projects, such as [godep](https://github.com/tools/godep). Once this is set up, you can install
+Gophercloud as a dependency like so:
+
+```bash
+go get github.com/gophercloud/gophercloud
+
+# Edit your code to import relevant packages from "github.com/gophercloud/gophercloud"
+
+godep save ./...
+```
+
+This will install all the source files you need into a `Godeps/_workspace` directory, which is
+referenceable from your own source files when you use the `godep go` command.
+
+## Getting started
+
+### Credentials
+
+Because you'll be hitting an API, you will need to retrieve your OpenStack
+credentials and either store them as environment variables or in your local Go
+files. The first method is recommended because it decouples credential
+information from source code, allowing you to push the latter to your version
+control system without any security risk.
+
+You will need to retrieve the following:
+
+* username
+* password
+* a valid Keystone identity URL
+
+For users that have the OpenStack dashboard installed, there's a shortcut. If
+you visit the `project/access_and_security` path in Horizon and click on the
+"Download OpenStack RC File" button at the top right hand corner, you will
+download a bash file that exports all of your access details to environment
+variables. To execute the file, run `source admin-openrc.sh` and you will be
+prompted for your password.
+
+### Authentication
+
+Once you have access to your credentials, you can begin plugging them into
+Gophercloud. The next step is authentication, and this is handled by a base
+"Provider" struct. To get one, you can either pass in your credentials
+explicitly, or tell Gophercloud to use environment variables:
+
+```go
+import (
+  "github.com/gophercloud/gophercloud"
+  "github.com/gophercloud/gophercloud/openstack"
+  "github.com/gophercloud/gophercloud/openstack/utils"
+)
+
+// Option 1: Pass in the values yourself
+opts := gophercloud.AuthOptions{
+  IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+  Username: "{username}",
+  Password: "{password}",
+}
+
+// Option 2: Use a utility function to retrieve all your environment variables
+opts, err := openstack.AuthOptionsFromEnv()
+```
+
+Once you have the `opts` variable, you can pass it in and get back a
+`ProviderClient` struct:
+
+```go
+provider, err := openstack.AuthenticatedClient(opts)
+```
+
+The `ProviderClient` is the top-level client that all of your OpenStack services
+derive from. The provider contains all of the authentication details that allow
+your Go code to access the API - such as the base URL and token ID.
+
+### Provision a server
+
+Once we have a base Provider, we inject it as a dependency into each OpenStack
+service. In order to work with the Compute API, we need a Compute service
+client; which can be created like so:
+
+```go
+client, err := openstack.NewComputeV2(provider, gophercloud.EndpointOpts{
+  Region: os.Getenv("OS_REGION_NAME"),
+})
+```
+
+We then use this `client` for any Compute API operation we want. In our case,
+we want to provision a new server - so we invoke the `Create` method and pass
+in the flavor ID (hardware specification) and image ID (operating system) we're
+interested in:
+
+```go
+import "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+
+server, err := servers.Create(client, servers.CreateOpts{
+  Name:      "My new server!",
+  FlavorRef: "flavor_id",
+  ImageRef:  "image_id",
+}).Extract()
+```
+
+The above code sample creates a new server with the parameters, and embodies the
+new resource in the `server` variable (a
+[`servers.Server`](http://godoc.org/github.com/gophercloud/gophercloud) struct).
+
+### Next steps
+
+Cool! You've handled authentication, got your `ProviderClient` and provisioned
+a new server. You're now ready to use more OpenStack services.
+
+* [Getting started with Compute](http://gophercloud.io/docs/compute)
+* [Getting started with Object Storage](http://gophercloud.io/docs/object-storage)
+* [Getting started with Networking](http://gophercloud.io/docs/networking)
+* [Getting started with Block Storage](http://gophercloud.io/docs/block-storage)
+* [Getting started with Identity](http://gophercloud.io/docs/identity)
+
+## Contributing
+
+Engaging the community and lowering barriers for contributors is something we
+care a lot about. For this reason, we've taken the time to write a [contributing
+guide](./CONTRIBUTING.md) for folks interested in getting involved in our project.
+If you're not sure how you can get involved, feel free to submit an issue or
+[contact us](https://developer.rackspace.com/support/). You don't need to be a
+Go expert - all members of the community are welcome!
+
+## Help and feedback
+
+If you're struggling with something or have spotted a potential bug, feel free
+to submit an issue to our [bug tracker](/issues) or [contact us directly](https://developer.rackspace.com/support/).
diff --git a/UPGRADING.md b/UPGRADING.md
new file mode 100644
index 0000000..76a94d5
--- /dev/null
+++ b/UPGRADING.md
@@ -0,0 +1,338 @@
+# Upgrading to v1.0.0
+
+With the arrival of this new major version increment, the unfortunate news is
+that breaking changes have been introduced to existing services. The API
+has been completely rewritten from the ground up to make the library more
+extensible, maintainable and easy-to-use.
+
+Below we've compiled upgrade instructions for the various services that
+existed before. If you have a specific issue that is not addressed below,
+please [submit an issue](/issues/new) or
+[e-mail our support team](https://developer.rackspace.com/support/).
+
+* [Authentication](#authentication)
+* [Servers](#servers)
+  * [List servers](#list-servers)
+  * [Get server details](#get-server-details)
+  * [Create server](#create-server)
+  * [Resize server](#resize-server)
+  * [Reboot server](#reboot-server)
+  * [Update server](#update-server)
+  * [Rebuild server](#rebuild-server)
+  * [Change admin password](#change-admin-password)
+  * [Delete server](#delete-server)
+  * [Rescue server](#rescue-server)
+* [Images and flavors](#images-and-flavors)
+  * [List images](#list-images)
+  * [List flavors](#list-flavors)
+  * [Create/delete image](#createdelete-image)
+* [Other](#other)
+  * [List keypairs](#list-keypairs)
+  * [Create/delete keypair](#createdelete-keypair)
+  * [List IP addresses](#list-ip-addresses)
+
+# Authentication
+
+One of the major differences that this release introduces is the level of
+sub-packaging to differentiate between services and providers. You now have
+the option of authenticating with OpenStack and other providers (like Rackspace).
+
+To authenticate with a vanilla OpenStack installation, you can either specify
+your credentials like this:
+
+```go
+import (
+  "github.com/rackspace/gophercloud"
+  "github.com/rackspace/gophercloud/openstack"
+)
+
+opts := gophercloud.AuthOptions{
+  IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+  Username: "{username}",
+  Password: "{password}",
+  TenantID: "{tenant_id}",
+}
+```
+
+Or have them pulled in through environment variables, like this:
+
+```go
+opts, err := openstack.AuthOptionsFromEnv()
+```
+
+Once you have your `AuthOptions` struct, you pass it in to get back a `Provider`,
+like so:
+
+```go
+provider, err := openstack.AuthenticatedClient(opts)
+```
+
+This provider is the top-level structure that all services are created from.
+
+# Servers
+
+Before you can interact with the Compute API, you need to retrieve a
+`gophercloud.ServiceClient`. To do this:
+
+```go
+// Define your region, etc.
+opts := gophercloud.EndpointOpts{Region: "RegionOne"}
+
+client, err := openstack.NewComputeV2(provider, opts)
+```
+
+## List servers
+
+All operations that involve API collections (servers, flavors, images) now use
+the `pagination.Pager` interface. This interface represents paginated entities
+that can be iterated over.
+
+Once you have a Pager, you can then pass a callback function into its `EachPage`
+method, and this will allow you to traverse over the collection and execute
+arbitrary functionality. So, an example with list servers:
+
+```go
+import (
+  "fmt"
+  "github.com/rackspace/gophercloud/pagination"
+  "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// We have the option of filtering the server list. If we want the full
+// collection, leave it as an empty struct or nil
+opts := servers.ListOpts{Name: "server_1"}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := servers.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+  serverList, err := servers.ExtractServers(page)
+
+  // `s' will be a servers.Server struct
+  for _, s := range serverList {
+    fmt.Printf("We have a server. ID=%s, Name=%s", s.ID, s.Name)
+  }
+})
+```
+
+## Get server details
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// Get the HTTP result
+response := servers.Get(client, "server_id")
+
+// Extract a Server struct from the response
+server, err := response.Extract()
+```
+
+## Create server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// Define our options
+opts := servers.CreateOpts{
+  Name: "new_server",
+  FlavorRef: "flavorID",
+  ImageRef: "imageID",
+}
+
+// Get our response
+response := servers.Create(client, opts)
+
+// Extract
+server, err := response.Extract()
+```
+
+## Change admin password
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+result := servers.ChangeAdminPassword(client, "server_id", "newPassword_&123")
+```
+
+## Resize server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+result := servers.Resize(client, "server_id", "new_flavor_id")
+```
+
+## Reboot server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// You have a choice of two reboot methods: servers.SoftReboot or servers.HardReboot
+result := servers.Reboot(client, "server_id", servers.SoftReboot)
+```
+
+## Update server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+opts := servers.UpdateOpts{Name: "new_name"}
+
+server, err := servers.Update(client, "server_id", opts).Extract()
+```
+
+## Rebuild server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+// You have the option of specifying additional options
+opts := RebuildOpts{
+  Name:      "new_name",
+  AdminPass: "admin_password",
+  ImageID:   "image_id",
+  Metadata:  map[string]string{"owner": "me"},
+}
+
+result := servers.Rebuild(client, "server_id", opts)
+
+// You can extract a servers.Server struct from the HTTP response
+server, err := result.Extract()
+```
+
+## Delete server
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+
+response := servers.Delete(client, "server_id")
+```
+
+## Rescue server
+
+The server rescue extension for Compute is not currently supported.
+
+# Images and flavors
+
+## List images
+
+As with listing servers (see above), you first retrieve a Pager, and then pass
+in a callback over each page:
+
+```go
+import (
+  "github.com/rackspace/gophercloud/pagination"
+  "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+)
+
+// We have the option of filtering the image list. If we want the full
+// collection, leave it as an empty struct
+opts := images.ListOpts{ChangesSince: "2014-01-01T01:02:03Z", Name: "Ubuntu 12.04"}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := images.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+  imageList, err := images.ExtractImages(page)
+
+  for _, i := range imageList {
+    // "i" will be an images.Image
+  }
+})
+```
+
+## List flavors
+
+```go
+import (
+  "github.com/rackspace/gophercloud/pagination"
+  "github.com/rackspace/gophercloud/openstack/compute/v2/flavors"
+)
+
+// We have the option of filtering the flavor list. If we want the full
+// collection, leave it as an empty struct
+opts := flavors.ListOpts{ChangesSince: "2014-01-01T01:02:03Z", MinRAM: 4}
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := flavors.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+  flavorList, err := networks.ExtractFlavors(page)
+
+  for _, f := range flavorList {
+    // "f" will be a flavors.Flavor
+  }
+})
+```
+
+## Create/delete image
+
+Image management has been shifted to Glance, but unfortunately this service is
+not supported as of yet. You can, however, list Compute images like so:
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/images"
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := images.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+  imageList, err := images.ExtractImages(page)
+
+  for _, i := range imageList {
+    // "i" will be an images.Image
+  }
+})
+```
+
+# Other
+
+## List keypairs
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+
+// Retrieve a pager (i.e. a paginated collection)
+pager := keypairs.List(client, opts)
+
+// Define an anonymous function to be executed on each page's iteration
+err := pager.EachPage(func(page pagination.Page) (bool, error) {
+  keyList, err := keypairs.ExtractKeyPairs(page)
+
+  for _, k := range keyList {
+    // "k" will be a keypairs.KeyPair
+  }
+})
+```
+
+## Create/delete keypairs
+
+To create a new keypair, you need to specify its name and, optionally, a
+pregenerated OpenSSH-formatted public key.
+
+```go
+import "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
+
+opts := keypairs.CreateOpts{
+  Name: "new_key",
+  PublicKey: "...",
+}
+
+response := keypairs.Create(client, opts)
+
+key, err := response.Extract()
+```
+
+To delete an existing keypair:
+
+```go
+response := keypairs.Delete(client, "keypair_id")
+```
+
+## List IP addresses
+
+This operation is not currently supported.
diff --git a/acceptance/README.md b/acceptance/README.md
new file mode 100644
index 0000000..3199837
--- /dev/null
+++ b/acceptance/README.md
@@ -0,0 +1,57 @@
+# Gophercloud Acceptance tests
+
+The purpose of these acceptance tests is to validate that SDK features meet
+the requirements of a contract - to consumers, other parts of the library, and
+to a remote API.
+
+> **Note:** Because every test will be run against a real API endpoint, you
+> may incur bandwidth and service charges for all the resource usage. These
+> tests *should* remove their remote products automatically. However, there may
+> be certain cases where this does not happen; always double-check to make sure
+> you have no stragglers left behind.
+
+### Step 1. Set environment variables
+
+A lot of tests rely on environment variables for configuration - so you will need
+to set them before running the suite. If you're testing against pure OpenStack APIs,
+you can download a file that contains all of these variables for you: just visit
+the `project/access_and_security` page in your control panel and click the "Download
+OpenStack RC File" button at the top right. For all other providers, you will need
+to set them manually.
+
+#### Authentication
+
+|Name|Description|
+|---|---|
+|`OS_USERNAME`|Your API username|
+|`OS_PASSWORD`|Your API password|
+|`OS_AUTH_URL`|The identity URL you need to authenticate|
+|`OS_TENANT_NAME`|Your API tenant name|
+|`OS_TENANT_ID`|Your API tenant ID|
+|`RS_USERNAME`|Your Rackspace username|
+|`RS_API_KEY`|Your Rackspace API key|
+
+#### General
+
+|Name|Description|
+|---|---|
+|`OS_REGION_NAME`|The region you want your resources to reside in|
+|`RS_REGION`|Rackspace region you want your resource to reside in|
+
+#### Compute
+
+|Name|Description|
+|---|---|
+|`OS_IMAGE_ID`|The ID of the image your want your server to be based on|
+|`OS_FLAVOR_ID`|The ID of the flavor you want your server to be based on|
+|`OS_FLAVOR_ID_RESIZE`|The ID of the flavor you want your server to be resized to|
+|`RS_IMAGE_ID`|The ID of the image you want servers to be created with|
+|`RS_FLAVOR_ID`|The ID of the flavor you want your server to be created with|
+
+### 2. Run the test suite
+
+From the root directory, run:
+
+```
+./script/acceptancetest
+```
diff --git a/acceptance/openstack/blockstorage/v1/pkg.go b/acceptance/openstack/blockstorage/v1/pkg.go
new file mode 100644
index 0000000..4efa6fb
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/pkg.go
@@ -0,0 +1,2 @@
+// Package v1 contains openstack cinder acceptance tests
+package v1
diff --git a/acceptance/openstack/blockstorage/v1/snapshots_test.go b/acceptance/openstack/blockstorage/v1/snapshots_test.go
new file mode 100644
index 0000000..2b737e7
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/snapshots_test.go
@@ -0,0 +1,70 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/blockstorage/v1/snapshots"
+	"github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestSnapshots(t *testing.T) {
+
+	client, err := newClient(t)
+	th.AssertNoErr(t, err)
+
+	v, err := volumes.Create(client, &volumes.CreateOpts{
+		Name: "gophercloud-test-volume",
+		Size: 1,
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	err = volumes.WaitForStatus(client, v.ID, "available", 120)
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created volume: %v\n", v)
+
+	ss, err := snapshots.Create(client, &snapshots.CreateOpts{
+		Name:     "gophercloud-test-snapshot",
+		VolumeID: v.ID,
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	err = snapshots.WaitForStatus(client, ss.ID, "available", 120)
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created snapshot: %+v\n", ss)
+
+	err = snapshots.Delete(client, ss.ID).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	err = gophercloud.WaitFor(120, func() (bool, error) {
+		_, err := snapshots.Get(client, ss.ID).Extract()
+		if err != nil {
+			return true, nil
+		}
+
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+
+	t.Log("Deleted snapshot\n")
+
+	err = volumes.Delete(client, v.ID).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	err = gophercloud.WaitFor(120, func() (bool, error) {
+		_, err := volumes.Get(client, v.ID).Extract()
+		if err != nil {
+			return true, nil
+		}
+
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+
+	t.Log("Deleted volume\n")
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumes_test.go b/acceptance/openstack/blockstorage/v1/volumes_test.go
new file mode 100644
index 0000000..a7bf123
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumes_test.go
@@ -0,0 +1,63 @@
+// +build acceptance blockstorage
+
+package v1
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) (*gophercloud.ServiceClient, error) {
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := openstack.AuthenticatedClient(ao)
+	th.AssertNoErr(t, err)
+
+	return openstack.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func TestVolumes(t *testing.T) {
+	client, err := newClient(t)
+	th.AssertNoErr(t, err)
+
+	cv, err := volumes.Create(client, &volumes.CreateOpts{
+		Size: 1,
+		Name: "gophercloud-test-volume",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	defer func() {
+		err = volumes.WaitForStatus(client, cv.ID, "available", 60)
+		th.AssertNoErr(t, err)
+		err = volumes.Delete(client, cv.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+	}()
+
+	_, err = volumes.Update(client, cv.ID, &volumes.UpdateOpts{
+		Name: "gophercloud-updated-volume",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	v, err := volumes.Get(client, cv.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got volume: %+v\n", v)
+
+	if v.Name != "gophercloud-updated-volume" {
+		t.Errorf("Unable to update volume: Expected name: gophercloud-updated-volume\nActual name: %s", v.Name)
+	}
+
+	err = volumes.List(client, &volumes.ListOpts{Name: "gophercloud-updated-volume"}).EachPage(func(page pagination.Page) (bool, error) {
+		vols, err := volumes.ExtractVolumes(page)
+		th.CheckEquals(t, 1, len(vols))
+		return true, err
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/blockstorage/v1/volumetypes_test.go b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
new file mode 100644
index 0000000..ebfa3de
--- /dev/null
+++ b/acceptance/openstack/blockstorage/v1/volumetypes_test.go
@@ -0,0 +1,49 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumetypes"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestVolumeTypes(t *testing.T) {
+	client, err := newClient(t)
+	th.AssertNoErr(t, err)
+
+	vt, err := volumetypes.Create(client, &volumetypes.CreateOpts{
+		ExtraSpecs: map[string]interface{}{
+			"capabilities": "gpu",
+			"priority":     3,
+		},
+		Name: "gophercloud-test-volumeType",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	defer func() {
+		time.Sleep(10000 * time.Millisecond)
+		err = volumetypes.Delete(client, vt.ID).ExtractErr()
+		if err != nil {
+			t.Error(err)
+			return
+		}
+	}()
+	t.Logf("Created volume type: %+v\n", vt)
+
+	vt, err = volumetypes.Get(client, vt.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got volume type: %+v\n", vt)
+
+	err = volumetypes.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		volTypes, err := volumetypes.ExtractVolumeTypes(page)
+		if len(volTypes) != 1 {
+			t.Errorf("Expected 1 volume type, got %d", len(volTypes))
+		}
+		t.Logf("Listing volume types: %+v\n", volTypes)
+		return true, err
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/client_test.go b/acceptance/openstack/client_test.go
new file mode 100644
index 0000000..2758f60
--- /dev/null
+++ b/acceptance/openstack/client_test.go
@@ -0,0 +1,40 @@
+// +build acceptance
+
+package openstack
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+)
+
+func TestAuthenticatedClient(t *testing.T) {
+	// Obtain credentials from the environment.
+	ao, err := openstack.AuthOptionsFromEnv()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	client, err := openstack.AuthenticatedClient(ao)
+	if err != nil {
+		t.Fatalf("Unable to authenticate: %v", err)
+	}
+
+	if client.TokenID == "" {
+		t.Errorf("No token ID assigned to the client")
+	}
+
+	t.Logf("Client successfully acquired a token: %v", client.TokenID)
+
+	// Find the storage service in the service catalog.
+	storage, err := openstack.NewObjectStorageV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+	if err != nil {
+		t.Errorf("Unable to locate a storage service: %v", err)
+	} else {
+		t.Logf("Located a storage service at endpoint: [%s]", storage.Endpoint)
+	}
+}
diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go
new file mode 100644
index 0000000..d1607be
--- /dev/null
+++ b/acceptance/openstack/compute/v2/bootfromvolume_test.go
@@ -0,0 +1,116 @@
+// +build acceptance
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/bootfromvolume"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestBootFromVolume(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	name := tools.RandomString("Gophercloud-", 8)
+	t.Logf("Creating server [%s].", name)
+
+	bd := []bootfromvolume.BlockDevice{
+		bootfromvolume.BlockDevice{
+			UUID:       choices.ImageID,
+			SourceType: bootfromvolume.Image,
+			VolumeSize: 10,
+		},
+	}
+
+	serverCreateOpts := servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+	}
+	server, err := bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{
+		serverCreateOpts,
+		bd,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("Created server: %+v\n", server)
+	defer servers.Delete(client, server.ID)
+	t.Logf("Deleting server [%s]...", name)
+}
+
+func TestMultiEphemeral(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	name := tools.RandomString("Gophercloud-", 8)
+	t.Logf("Creating server [%s].", name)
+
+	bd := []bootfromvolume.BlockDevice{
+		bootfromvolume.BlockDevice{
+			BootIndex:           0,
+			UUID:                choices.ImageID,
+			SourceType:          bootfromvolume.Image,
+			DestinationType:     "local",
+			DeleteOnTermination: true,
+		},
+		bootfromvolume.BlockDevice{
+			BootIndex:           -1,
+			SourceType:          bootfromvolume.Blank,
+			DestinationType:     "local",
+			DeleteOnTermination: true,
+			GuestFormat:         "ext4",
+			VolumeSize:          1,
+		},
+		bootfromvolume.BlockDevice{
+			BootIndex:           -1,
+			SourceType:          bootfromvolume.Blank,
+			DestinationType:     "local",
+			DeleteOnTermination: true,
+			GuestFormat:         "ext4",
+			VolumeSize:          1,
+		},
+	}
+
+	serverCreateOpts := servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+	}
+	server, err := bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{
+		serverCreateOpts,
+		bd,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("Created server: %+v\n", server)
+	defer servers.Delete(client, server.ID)
+	t.Logf("Deleting server [%s]...", name)
+}
diff --git a/acceptance/openstack/compute/v2/compute_test.go b/acceptance/openstack/compute/v2/compute_test.go
new file mode 100644
index 0000000..83b0e35
--- /dev/null
+++ b/acceptance/openstack/compute/v2/compute_test.go
@@ -0,0 +1,104 @@
+// +build acceptance common
+
+package v2
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+func newClient() (*gophercloud.ServiceClient, error) {
+	ao, err := openstack.AuthOptionsFromEnv()
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := openstack.AuthenticatedClient(ao)
+	if err != nil {
+		return nil, err
+	}
+
+	return openstack.NewComputeV2(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func waitForStatus(client *gophercloud.ServiceClient, server *servers.Server, status string) error {
+	return tools.WaitFor(func() (bool, error) {
+		latest, err := servers.Get(client, server.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if latest.Status == status {
+			// Success!
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
+
+// ComputeChoices contains image and flavor selections for use by the acceptance tests.
+type ComputeChoices struct {
+	// ImageID contains the ID of a valid image.
+	ImageID string
+
+	// FlavorID contains the ID of a valid flavor.
+	FlavorID string
+
+	// FlavorIDResize contains the ID of a different flavor available on the same OpenStack installation, that is distinct
+	// from FlavorID.
+	FlavorIDResize string
+
+	// NetworkName is the name of a network to launch the instance on.
+	NetworkName string
+}
+
+// ComputeChoicesFromEnv populates a ComputeChoices struct from environment variables.
+// If any required state is missing, an `error` will be returned that enumerates the missing properties.
+func ComputeChoicesFromEnv() (*ComputeChoices, error) {
+	imageID := os.Getenv("OS_IMAGE_ID")
+	flavorID := os.Getenv("OS_FLAVOR_ID")
+	flavorIDResize := os.Getenv("OS_FLAVOR_ID_RESIZE")
+	networkName := os.Getenv("OS_NETWORK_NAME")
+
+	missing := make([]string, 0, 3)
+	if imageID == "" {
+		missing = append(missing, "OS_IMAGE_ID")
+	}
+	if flavorID == "" {
+		missing = append(missing, "OS_FLAVOR_ID")
+	}
+	if flavorIDResize == "" {
+		missing = append(missing, "OS_FLAVOR_ID_RESIZE")
+	}
+	if networkName == "" {
+		networkName = "public"
+	}
+
+	notDistinct := ""
+	if flavorID == flavorIDResize {
+		notDistinct = "OS_FLAVOR_ID and OS_FLAVOR_ID_RESIZE must be distinct."
+	}
+
+	if len(missing) > 0 || notDistinct != "" {
+		text := "You're missing some important setup:\n"
+		if len(missing) > 0 {
+			text += " * These environment variables must be provided: " + strings.Join(missing, ", ") + "\n"
+		}
+		if notDistinct != "" {
+			text += " * " + notDistinct + "\n"
+		}
+
+		return nil, fmt.Errorf(text)
+	}
+
+	return &ComputeChoices{ImageID: imageID, FlavorID: flavorID, FlavorIDResize: flavorIDResize, NetworkName: networkName}, nil
+}
diff --git a/acceptance/openstack/compute/v2/extension_test.go b/acceptance/openstack/compute/v2/extension_test.go
new file mode 100644
index 0000000..0fd6fec
--- /dev/null
+++ b/acceptance/openstack/compute/v2/extension_test.go
@@ -0,0 +1,47 @@
+// +build acceptance compute extensionss
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListExtensions(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	err = extensions.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		exts, err := extensions.ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		for i, ext := range exts {
+			t.Logf("[%02d]    name=[%s]\n", i, ext.Name)
+			t.Logf("       alias=[%s]\n", ext.Alias)
+			t.Logf(" description=[%s]\n", ext.Description)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
+
+func TestGetExtension(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	ext, err := extensions.Get(client, "os-admin-actions").Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Extension details:")
+	t.Logf("        name=[%s]\n", ext.Name)
+	t.Logf("   namespace=[%s]\n", ext.Namespace)
+	t.Logf("       alias=[%s]\n", ext.Alias)
+	t.Logf(" description=[%s]\n", ext.Description)
+	t.Logf("     updated=[%s]\n", ext.Updated)
+}
diff --git a/acceptance/openstack/compute/v2/flavors_test.go b/acceptance/openstack/compute/v2/flavors_test.go
new file mode 100644
index 0000000..365281d
--- /dev/null
+++ b/acceptance/openstack/compute/v2/flavors_test.go
@@ -0,0 +1,57 @@
+// +build acceptance compute flavors
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/flavors"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func TestListFlavors(t *testing.T) {
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	t.Logf("ID\tRegion\tName\tStatus\tCreated")
+
+	pager := flavors.ListDetail(client, nil)
+	count, pages := 0, 0
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("---")
+		pages++
+		flavors, err := flavors.ExtractFlavors(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, f := range flavors {
+			t.Logf("%s\t%s\t%d\t%d\t%d", f.ID, f.Name, f.RAM, f.Disk, f.VCPUs)
+		}
+
+		return true, nil
+	})
+
+	t.Logf("--------\n%d flavors listed on %d pages.", count, pages)
+}
+
+func TestGetFlavor(t *testing.T) {
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	flavor, err := flavors.Get(client, choices.FlavorID).Extract()
+	if err != nil {
+		t.Fatalf("Unable to get flavor information: %v", err)
+	}
+
+	t.Logf("Flavor: %#v", flavor)
+}
diff --git a/acceptance/openstack/compute/v2/floatingip_test.go b/acceptance/openstack/compute/v2/floatingip_test.go
new file mode 100644
index 0000000..8231bd6
--- /dev/null
+++ b/acceptance/openstack/compute/v2/floatingip_test.go
@@ -0,0 +1,168 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingip"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func createFIPServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	server, err := servers.Create(client, servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		AdminPass: pwd,
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func createFloatingIP(t *testing.T, client *gophercloud.ServiceClient) (*floatingip.FloatingIP, error) {
+	pool := os.Getenv("OS_POOL_NAME")
+	fip, err := floatingip.Create(client, &floatingip.CreateOpts{
+		Pool: pool,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Obtained Floating IP: %v", fip.IP)
+
+	return fip, err
+}
+
+func associateFloatingIPDeprecated(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+	// This form works, but is considered deprecated.
+	// See associateFloatingIP or associateFloatingIPFixed
+	err := floatingip.Associate(client, serverId, fip.IP).ExtractErr()
+	th.AssertNoErr(t, err)
+	t.Logf("Associated floating IP %v from instance %v", fip.IP, serverId)
+	defer func() {
+		err = floatingip.Disassociate(client, serverId, fip.IP).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Disassociated floating IP %v from instance %v", fip.IP, serverId)
+	}()
+	floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func associateFloatingIP(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+	associateOpts := floatingip.AssociateOpts{
+		ServerID:   serverId,
+		FloatingIP: fip.IP,
+	}
+
+	err := floatingip.AssociateInstance(client, associateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+	t.Logf("Associated floating IP %v from instance %v", fip.IP, serverId)
+	defer func() {
+		err = floatingip.DisassociateInstance(client, associateOpts).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Disassociated floating IP %v from instance %v", fip.IP, serverId)
+	}()
+	floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func associateFloatingIPFixed(t *testing.T, client *gophercloud.ServiceClient, serverId string, fip *floatingip.FloatingIP) {
+
+	network := os.Getenv("OS_NETWORK_NAME")
+	server, err := servers.Get(client, serverId).Extract()
+	if err != nil {
+		t.Fatalf("%s", err)
+	}
+
+	var fixedIP string
+	for _, networkAddresses := range server.Addresses[network].([]interface{}) {
+		address := networkAddresses.(map[string]interface{})
+		if address["OS-EXT-IPS:type"] == "fixed" {
+			if address["version"].(float64) == 4 {
+				fixedIP = address["addr"].(string)
+			}
+		}
+	}
+
+	associateOpts := floatingip.AssociateOpts{
+		ServerID:   serverId,
+		FloatingIP: fip.IP,
+		FixedIP:    fixedIP,
+	}
+
+	err = floatingip.AssociateInstance(client, associateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+	t.Logf("Associated floating IP %v from instance %v with Fixed IP %v", fip.IP, serverId, fixedIP)
+	defer func() {
+		err = floatingip.DisassociateInstance(client, associateOpts).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Disassociated floating IP %v from instance %v with Fixed IP %v", fip.IP, serverId, fixedIP)
+	}()
+	floatingIp, err := floatingip.Get(client, fip.ID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, floatingIp.FixedIP, fixedIP)
+	t.Logf("Floating IP %v is associated with Fixed IP %v", fip.IP, floatingIp.FixedIP)
+}
+
+func TestFloatingIP(t *testing.T) {
+	pool := os.Getenv("OS_POOL_NAME")
+	if pool == "" {
+		t.Fatalf("OS_POOL_NAME must be set")
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	server, err := createFIPServer(t, client, choices)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(client, server.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	fip, err := createFloatingIP(t, client)
+	if err != nil {
+		t.Fatalf("Unable to create floating IP: %v", err)
+	}
+	defer func() {
+		err = floatingip.Delete(client, fip.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Floating IP deleted.")
+	}()
+
+	associateFloatingIPDeprecated(t, client, server.ID, fip)
+	associateFloatingIP(t, client, server.ID, fip)
+	associateFloatingIPFixed(t, client, server.ID, fip)
+
+}
diff --git a/acceptance/openstack/compute/v2/images_test.go b/acceptance/openstack/compute/v2/images_test.go
new file mode 100644
index 0000000..9b740ad
--- /dev/null
+++ b/acceptance/openstack/compute/v2/images_test.go
@@ -0,0 +1,37 @@
+// +build acceptance compute images
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func TestListImages(t *testing.T) {
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute: client: %v", err)
+	}
+
+	t.Logf("ID\tRegion\tName\tStatus\tCreated")
+
+	pager := images.ListDetail(client, nil)
+	count, pages := 0, 0
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+		images, err := images.ExtractImages(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, i := range images {
+			t.Logf("%s\t%s\t%s\t%s", i.ID, i.Name, i.Status, i.Created)
+		}
+
+		return true, nil
+	})
+
+	t.Logf("--------\n%d images listed on %d pages.", count, pages)
+}
diff --git a/acceptance/openstack/compute/v2/keypairs_test.go b/acceptance/openstack/compute/v2/keypairs_test.go
new file mode 100644
index 0000000..326c6f9
--- /dev/null
+++ b/acceptance/openstack/compute/v2/keypairs_test.go
@@ -0,0 +1,74 @@
+// +build acceptance
+
+package v2
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+
+	"golang.org/x/crypto/ssh"
+)
+
+const keyName = "gophercloud_test_key_pair"
+
+func TestCreateServerWithKeyPair(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	publicKey := privateKey.PublicKey
+	pub, err := ssh.NewPublicKey(&publicKey)
+	th.AssertNoErr(t, err)
+	pubBytes := ssh.MarshalAuthorizedKey(pub)
+	pk := string(pubBytes)
+
+	kp, err := keypairs.Create(client, keypairs.CreateOpts{
+		Name:      keyName,
+		PublicKey: pk,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created key pair: %s\n", kp)
+
+	choices, err := ComputeChoicesFromEnv()
+	th.AssertNoErr(t, err)
+
+	name := tools.RandomString("Gophercloud-", 8)
+	t.Logf("Creating server [%s] with key pair.", name)
+
+	serverCreateOpts := servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+	}
+
+	server, err := servers.Create(client, keypairs.CreateOptsExt{
+		serverCreateOpts,
+		keyName,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	defer servers.Delete(client, server.ID)
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	server, err = servers.Get(client, server.ID).Extract()
+	t.Logf("Created server: %+v\n", server)
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, server.KeyName, keyName)
+
+	t.Logf("Deleting key pair [%s]...", kp.Name)
+	err = keypairs.Delete(client, keyName).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Deleting server [%s]...", name)
+}
diff --git a/acceptance/openstack/compute/v2/network_test.go b/acceptance/openstack/compute/v2/network_test.go
new file mode 100644
index 0000000..615e391
--- /dev/null
+++ b/acceptance/openstack/compute/v2/network_test.go
@@ -0,0 +1,78 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/networks"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func getNetworkIDFromNetworkExtension(t *testing.T, client *gophercloud.ServiceClient, networkName string) (string, error) {
+	allPages, err := networks.List(client).AllPages()
+	if err != nil {
+		t.Fatalf("Unable to list networks: %v", err)
+	}
+
+	networkList, err := networks.ExtractNetworks(allPages)
+	if err != nil {
+		t.Fatalf("Unable to list networks: %v", err)
+	}
+
+	networkID := ""
+	for _, network := range networkList {
+		t.Logf("Network: %v", network)
+		if network.Label == networkName {
+			networkID = network.ID
+		}
+	}
+
+	t.Logf("Found network ID for %s: %s\n", networkName, networkID)
+
+	return networkID, nil
+}
+
+func TestNetworks(t *testing.T) {
+	networkName := os.Getenv("OS_NETWORK_NAME")
+	if networkName == "" {
+		t.Fatalf("OS_NETWORK_NAME must be set")
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	networkID, err := getNetworkIDFromNetworkExtension(t, client, networkName)
+	if err != nil {
+		t.Fatalf("Unable to get network ID: %v", err)
+	}
+
+	// createNetworkServer is defined in tenantnetworks_test.go
+	server, err := createNetworkServer(t, client, choices, networkID)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(client, server.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	allPages, err := networks.List(client).AllPages()
+	allNetworks, err := networks.ExtractNetworks(allPages)
+	th.AssertNoErr(t, err)
+	t.Logf("Retrieved all %d networks: %+v", len(allNetworks), allNetworks)
+}
diff --git a/acceptance/openstack/compute/v2/pkg.go b/acceptance/openstack/compute/v2/pkg.go
new file mode 100644
index 0000000..a57c1e7
--- /dev/null
+++ b/acceptance/openstack/compute/v2/pkg.go
@@ -0,0 +1,2 @@
+// Package v2 package contains acceptance tests for the Openstack Compute V2 service.
+package v2
diff --git a/acceptance/openstack/compute/v2/secdefrules_test.go b/acceptance/openstack/compute/v2/secdefrules_test.go
new file mode 100644
index 0000000..15809e2
--- /dev/null
+++ b/acceptance/openstack/compute/v2/secdefrules_test.go
@@ -0,0 +1,72 @@
+// +build acceptance compute defsecrules
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	dsr "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/defsecrules"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestSecDefRules(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	id := createDefRule(t, client)
+
+	listDefRules(t, client)
+
+	getDefRule(t, client, id)
+
+	deleteDefRule(t, client, id)
+}
+
+func createDefRule(t *testing.T, client *gophercloud.ServiceClient) string {
+	opts := dsr.CreateOpts{
+		FromPort:   tools.RandomInt(80, 89),
+		ToPort:     tools.RandomInt(90, 99),
+		IPProtocol: "TCP",
+		CIDR:       "0.0.0.0/0",
+	}
+
+	rule, err := dsr.Create(client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created default rule %s", rule.ID)
+
+	return rule.ID
+}
+
+func listDefRules(t *testing.T, client *gophercloud.ServiceClient) {
+	err := dsr.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		drList, err := dsr.ExtractDefaultRules(page)
+		th.AssertNoErr(t, err)
+
+		for _, dr := range drList {
+			t.Logf("Listing default rule %s: Name [%s] From Port [%s] To Port [%s] Protocol [%s]",
+				dr.ID, dr.FromPort, dr.ToPort, dr.IPProtocol)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func getDefRule(t *testing.T, client *gophercloud.ServiceClient, id string) {
+	rule, err := dsr.Get(client, id).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting rule %s: %#v", id, rule)
+}
+
+func deleteDefRule(t *testing.T, client *gophercloud.ServiceClient, id string) {
+	err := dsr.Delete(client, id).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Deleted rule %s", id)
+}
diff --git a/acceptance/openstack/compute/v2/secgroup_test.go b/acceptance/openstack/compute/v2/secgroup_test.go
new file mode 100644
index 0000000..3100b1f
--- /dev/null
+++ b/acceptance/openstack/compute/v2/secgroup_test.go
@@ -0,0 +1,177 @@
+// +build acceptance compute secgroups
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestSecGroups(t *testing.T) {
+	client, err := newClient()
+	th.AssertNoErr(t, err)
+
+	serverID, needsDeletion := findServer(t, client)
+
+	groupID := createSecGroup(t, client)
+
+	listSecGroups(t, client)
+
+	newName := tools.RandomString("secgroup_", 5)
+	updateSecGroup(t, client, groupID, newName)
+
+	getSecGroup(t, client, groupID)
+
+	addRemoveRules(t, client, groupID)
+
+	addServerToSecGroup(t, client, serverID, newName)
+
+	removeServerFromSecGroup(t, client, serverID, newName)
+
+	if needsDeletion {
+		servers.Delete(client, serverID)
+	}
+
+	deleteSecGroup(t, client, groupID)
+}
+
+func createSecGroup(t *testing.T, client *gophercloud.ServiceClient) string {
+	opts := secgroups.CreateOpts{
+		Name:        tools.RandomString("secgroup_", 5),
+		Description: "something",
+	}
+
+	group, err := secgroups.Create(client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created secgroup %s %s", group.ID, group.Name)
+
+	return group.ID
+}
+
+func listSecGroups(t *testing.T, client *gophercloud.ServiceClient) {
+	err := secgroups.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		secGrpList, err := secgroups.ExtractSecurityGroups(page)
+		th.AssertNoErr(t, err)
+
+		for _, sg := range secGrpList {
+			t.Logf("Listing secgroup %s: Name [%s] Desc [%s] TenantID [%s]", sg.ID,
+				sg.Name, sg.Description, sg.TenantID)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updateSecGroup(t *testing.T, client *gophercloud.ServiceClient, id, newName string) {
+	opts := secgroups.UpdateOpts{
+		Name:        newName,
+		Description: tools.RandomString("dec_", 10),
+	}
+	group, err := secgroups.Update(client, id, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Updated %s's name to %s", group.ID, group.Name)
+}
+
+func getSecGroup(t *testing.T, client *gophercloud.ServiceClient, id string) {
+	group, err := secgroups.Get(client, id).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting %s: %#v", id, group)
+}
+
+func addRemoveRules(t *testing.T, client *gophercloud.ServiceClient, id string) {
+	opts := secgroups.CreateRuleOpts{
+		ParentGroupID: id,
+		FromPort:      22,
+		ToPort:        22,
+		IPProtocol:    "TCP",
+		CIDR:          "0.0.0.0/0",
+	}
+
+	rule, err := secgroups.CreateRule(client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Adding rule %s to group %s", rule.ID, id)
+
+	err = secgroups.DeleteRule(client, rule.ID).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Deleted rule %s from group %s", rule.ID, id)
+}
+
+func findServer(t *testing.T, client *gophercloud.ServiceClient) (string, bool) {
+	var serverID string
+	var needsDeletion bool
+
+	err := servers.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+		sList, err := servers.ExtractServers(page)
+		th.AssertNoErr(t, err)
+
+		for _, s := range sList {
+			serverID = s.ID
+			needsDeletion = false
+
+			t.Logf("Found an existing server: ID [%s]", serverID)
+			break
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	if serverID == "" {
+		t.Log("No server found, creating one")
+
+		choices, err := ComputeChoicesFromEnv()
+		th.AssertNoErr(t, err)
+
+		opts := &servers.CreateOpts{
+			Name:      tools.RandomString("secgroup_test_", 5),
+			ImageRef:  choices.ImageID,
+			FlavorRef: choices.FlavorID,
+		}
+
+		s, err := servers.Create(client, opts).Extract()
+		th.AssertNoErr(t, err)
+		serverID = s.ID
+
+		t.Logf("Created server %s, waiting for it to build", s.ID)
+		err = servers.WaitForStatus(client, serverID, "ACTIVE", 300)
+		th.AssertNoErr(t, err)
+
+		needsDeletion = true
+	}
+
+	return serverID, needsDeletion
+}
+
+func addServerToSecGroup(t *testing.T, client *gophercloud.ServiceClient, serverID, groupName string) {
+	err := secgroups.AddServerToGroup(client, serverID, groupName).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Adding group %s to server %s", groupName, serverID)
+}
+
+func removeServerFromSecGroup(t *testing.T, client *gophercloud.ServiceClient, serverID, groupName string) {
+	err := secgroups.RemoveServerFromGroup(client, serverID, groupName).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Removing group %s from server %s", groupName, serverID)
+}
+
+func deleteSecGroup(t *testing.T, client *gophercloud.ServiceClient, id string) {
+	err := secgroups.Delete(client, id).ExtractErr()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Deleted group %s", id)
+}
diff --git a/acceptance/openstack/compute/v2/servergroup_test.go b/acceptance/openstack/compute/v2/servergroup_test.go
new file mode 100644
index 0000000..79f7c92
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servergroup_test.go
@@ -0,0 +1,143 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/schedulerhints"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/servergroups"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func createServerGroup(t *testing.T, computeClient *gophercloud.ServiceClient) (*servergroups.ServerGroup, error) {
+	sg, err := servergroups.Create(computeClient, &servergroups.CreateOpts{
+		Name:     "test",
+		Policies: []string{"affinity"},
+	}).Extract()
+
+	if err != nil {
+		t.Fatalf("Unable to create server group: %v", err)
+	}
+
+	t.Logf("Created server group: %v", sg.ID)
+	t.Logf("It has policies: %v", sg.Policies)
+
+	return sg, nil
+}
+
+func getServerGroup(t *testing.T, computeClient *gophercloud.ServiceClient, sgID string) error {
+	sg, err := servergroups.Get(computeClient, sgID).Extract()
+	if err != nil {
+		t.Fatalf("Unable to get server group: %v", err)
+	}
+
+	t.Logf("Got server group: %v", sg.Name)
+
+	return nil
+}
+
+func createServerInGroup(t *testing.T, computeClient *gophercloud.ServiceClient, choices *ComputeChoices, serverGroup *servergroups.ServerGroup) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	serverCreateOpts := servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		AdminPass: pwd,
+	}
+	server, err := servers.Create(computeClient, schedulerhints.CreateOptsExt{
+		serverCreateOpts,
+		schedulerhints.SchedulerHints{
+			Group: serverGroup.ID,
+		},
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func verifySchedulerWorked(t *testing.T, firstServer, secondServer *servers.Server) error {
+	t.Logf("First server hostID: %v", firstServer.HostID)
+	t.Logf("Second server hostID: %v", secondServer.HostID)
+	if firstServer.HostID == secondServer.HostID {
+		return nil
+	}
+
+	return fmt.Errorf("%s and %s were not scheduled on the same host.", firstServer.ID, secondServer.ID)
+}
+
+func TestServerGroups(t *testing.T) {
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	computeClient, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	sg, err := createServerGroup(t, computeClient)
+	if err != nil {
+		t.Fatalf("Unable to create server group: %v", err)
+	}
+	defer func() {
+		servergroups.Delete(computeClient, sg.ID)
+		t.Logf("Server Group deleted.")
+	}()
+
+	err = getServerGroup(t, computeClient, sg.ID)
+	if err != nil {
+		t.Fatalf("Unable to get server group: %v", err)
+	}
+
+	firstServer, err := createServerInGroup(t, computeClient, choices, sg)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(computeClient, firstServer.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(computeClient, firstServer, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	firstServer, err = servers.Get(computeClient, firstServer.ID).Extract()
+
+	secondServer, err := createServerInGroup(t, computeClient, choices, sg)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(computeClient, secondServer.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(computeClient, secondServer, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	secondServer, err = servers.Get(computeClient, secondServer.ID).Extract()
+
+	if err = verifySchedulerWorked(t, firstServer, secondServer); err != nil {
+		t.Fatalf("Scheduling did not work: %v", err)
+	}
+}
diff --git a/acceptance/openstack/compute/v2/servers_test.go b/acceptance/openstack/compute/v2/servers_test.go
new file mode 100644
index 0000000..4c4fb42
--- /dev/null
+++ b/acceptance/openstack/compute/v2/servers_test.go
@@ -0,0 +1,484 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListServers(t *testing.T) {
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	t.Logf("ID\tRegion\tName\tStatus\tIPv4\tIPv6")
+
+	pager := servers.List(client, servers.ListOpts{})
+	count, pages := 0, 0
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+		t.Logf("---")
+
+		servers, err := servers.ExtractServers(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, s := range servers {
+			t.Logf("%s\t%s\t%s\t%s\t%s\t\n", s.ID, s.Name, s.Status, s.AccessIPv4, s.AccessIPv6)
+			count++
+		}
+
+		return true, nil
+	})
+
+	t.Logf("--------\n%d servers listed on %d pages.\n", count, pages)
+}
+
+func networkingClient() (*gophercloud.ServiceClient, error) {
+	opts, err := openstack.AuthOptionsFromEnv()
+	if err != nil {
+		return nil, err
+	}
+
+	provider, err := openstack.AuthenticatedClient(opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func createServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	var network networks.Network
+
+	networkingClient, err := networkingClient()
+	if err != nil {
+		t.Fatalf("Unable to create a networking client: %v", err)
+	}
+
+	pager := networks.List(networkingClient, networks.ListOpts{
+		Name:  choices.NetworkName,
+		Limit: 1,
+	})
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		networks, err := networks.ExtractNetworks(page)
+		if err != nil {
+			t.Errorf("Failed to extract networks: %v", err)
+			return false, err
+		}
+
+		if len(networks) == 0 {
+			t.Fatalf("No networks to attach to server")
+			return false, err
+		}
+
+		network = networks[0]
+
+		return false, nil
+	})
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	server, err := servers.Create(client, servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		Networks: []servers.Network{
+			servers.Network{UUID: network.ID},
+		},
+		AdminPass: pwd,
+		Personality: servers.Personality{
+			&servers.File{
+				Path:     "/etc/test",
+				Contents: []byte("hello world"),
+			},
+		},
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func TestCreateDestroyServer(t *testing.T) {
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(client, server.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	pager := servers.ListAddresses(client, server.ID)
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		networks, err := servers.ExtractAddresses(page)
+		if err != nil {
+			return false, err
+		}
+
+		for n, a := range networks {
+			t.Logf("%s: %+v\n", n, a)
+		}
+		return true, nil
+	})
+
+	pager = servers.ListAddressesByNetwork(client, server.ID, choices.NetworkName)
+	pager.EachPage(func(page pagination.Page) (bool, error) {
+		addresses, err := servers.ExtractNetworkAddresses(page)
+		if err != nil {
+			return false, err
+		}
+
+		for _, a := range addresses {
+			t.Logf("%+v\n", a)
+		}
+		return true, nil
+	})
+}
+
+func TestUpdateServer(t *testing.T) {
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	alternateName := tools.RandomString("ACPTTEST", 16)
+	for alternateName == server.Name {
+		alternateName = tools.RandomString("ACPTTEST", 16)
+	}
+
+	t.Logf("Attempting to rename the server to %s.", alternateName)
+
+	updated, err := servers.Update(client, server.ID, servers.UpdateOpts{Name: alternateName}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to rename server: %v", err)
+	}
+
+	if updated.ID != server.ID {
+		t.Errorf("Updated server ID [%s] didn't match original server ID [%s]!", updated.ID, server.ID)
+	}
+
+	err = tools.WaitFor(func() (bool, error) {
+		latest, err := servers.Get(client, updated.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		return latest.Name == alternateName, nil
+	})
+}
+
+func TestActionChangeAdminPassword(t *testing.T) {
+	t.Parallel()
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	randomPassword := tools.MakeNewPassword(server.AdminPass)
+	res := servers.ChangeAdminPassword(client, server.ID, randomPassword)
+	if res.Err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, server, "PASSWORD"); err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestActionReboot(t *testing.T) {
+	t.Parallel()
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	res := servers.Reboot(client, server.ID, "aldhjflaskhjf")
+	if res.Err == nil {
+		t.Fatal("Expected the SDK to provide an ArgumentError here")
+	}
+
+	t.Logf("Attempting reboot of server %s", server.ID)
+	res = servers.Reboot(client, server.ID, servers.OSReboot)
+	if res.Err != nil {
+		t.Fatalf("Unable to reboot server: %v", err)
+	}
+
+	if err = waitForStatus(client, server, "REBOOT"); err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestActionRebuild(t *testing.T) {
+	t.Parallel()
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("Attempting to rebuild server %s", server.ID)
+
+	rebuildOpts := servers.RebuildOpts{
+		Name:      tools.RandomString("ACPTTEST", 16),
+		AdminPass: tools.MakeNewPassword(server.AdminPass),
+		ImageID:   choices.ImageID,
+	}
+
+	rebuilt, err := servers.Rebuild(client, server.ID, rebuildOpts).Extract()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if rebuilt.ID != server.ID {
+		t.Errorf("Expected rebuilt server ID of [%s]; got [%s]", server.ID, rebuilt.ID)
+	}
+
+	if err = waitForStatus(client, rebuilt, "REBUILD"); err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, rebuilt, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func resizeServer(t *testing.T, client *gophercloud.ServiceClient, server *servers.Server, choices *ComputeChoices) {
+	if err := waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("Attempting to resize server [%s]", server.ID)
+
+	opts := &servers.ResizeOpts{
+		FlavorRef: choices.FlavorIDResize,
+	}
+	if res := servers.Resize(client, server.ID, opts); res.Err != nil {
+		t.Fatal(res.Err)
+	}
+
+	if err := waitForStatus(client, server, "VERIFY_RESIZE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestActionResizeConfirm(t *testing.T) {
+	t.Parallel()
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+	resizeServer(t, client, server, choices)
+
+	t.Logf("Attempting to confirm resize for server %s", server.ID)
+
+	if res := servers.ConfirmResize(client, server.ID); res.Err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestActionResizeRevert(t *testing.T) {
+	t.Parallel()
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+	resizeServer(t, client, server, choices)
+
+	t.Logf("Attempting to revert resize for server %s", server.ID)
+
+	if res := servers.RevertResize(client, server.ID); res.Err != nil {
+		t.Fatal(err)
+	}
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestServerMetadata(t *testing.T) {
+	t.Parallel()
+
+	choices, err := ComputeChoicesFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	server, err := createServer(t, client, choices)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer servers.Delete(client, server.ID)
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatal(err)
+	}
+
+	metadata, err := servers.UpdateMetadata(client, server.ID, servers.MetadataOpts{
+		"foo":  "bar",
+		"this": "that",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("UpdateMetadata result: %+v\n", metadata)
+
+	err = servers.DeleteMetadatum(client, server.ID, "foo").ExtractErr()
+	th.AssertNoErr(t, err)
+
+	metadata, err = servers.CreateMetadatum(client, server.ID, servers.MetadatumOpts{
+		"foo": "baz",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("CreateMetadatum result: %+v\n", metadata)
+
+	metadata, err = servers.Metadatum(client, server.ID, "foo").Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Metadatum result: %+v\n", metadata)
+	th.AssertEquals(t, "baz", metadata["foo"])
+
+	metadata, err = servers.Metadata(client, server.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Metadata result: %+v\n", metadata)
+
+	metadata, err = servers.ResetMetadata(client, server.ID, servers.MetadataOpts{}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("ResetMetadata result: %+v\n", metadata)
+	th.AssertDeepEquals(t, map[string]string{}, metadata)
+}
diff --git a/acceptance/openstack/compute/v2/tenantnetworks_test.go b/acceptance/openstack/compute/v2/tenantnetworks_test.go
new file mode 100644
index 0000000..58208c0
--- /dev/null
+++ b/acceptance/openstack/compute/v2/tenantnetworks_test.go
@@ -0,0 +1,109 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/tenantnetworks"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func getNetworkID(t *testing.T, client *gophercloud.ServiceClient, networkName string) (string, error) {
+	allPages, err := tenantnetworks.List(client).AllPages()
+	if err != nil {
+		t.Fatalf("Unable to list networks: %v", err)
+	}
+
+	networkList, err := tenantnetworks.ExtractNetworks(allPages)
+	if err != nil {
+		t.Fatalf("Unable to list networks: %v", err)
+	}
+
+	networkID := ""
+	for _, network := range networkList {
+		t.Logf("Network: %v", network)
+		if network.Name == networkName {
+			networkID = network.ID
+		}
+	}
+
+	t.Logf("Found network ID for %s: %s\n", networkName, networkID)
+
+	return networkID, nil
+}
+
+func createNetworkServer(t *testing.T, client *gophercloud.ServiceClient, choices *ComputeChoices, networkID string) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	networks := make([]servers.Network, 1)
+	networks[0] = servers.Network{
+		UUID: networkID,
+	}
+
+	server, err := servers.Create(client, servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		AdminPass: pwd,
+		Networks:  networks,
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func TestTenantNetworks(t *testing.T) {
+	networkName := os.Getenv("OS_NETWORK_NAME")
+	if networkName == "" {
+		t.Fatalf("OS_NETWORK_NAME must be set")
+	}
+
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	networkID, err := getNetworkID(t, client, networkName)
+	if err != nil {
+		t.Fatalf("Unable to get network ID: %v", err)
+	}
+
+	server, err := createNetworkServer(t, client, choices, networkID)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(client, server.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(client, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	allPages, err := tenantnetworks.List(client).AllPages()
+	allNetworks, err := tenantnetworks.ExtractNetworks(allPages)
+	th.AssertNoErr(t, err)
+	t.Logf("Retrieved all %d networks: %+v", len(allNetworks), allNetworks)
+}
diff --git a/acceptance/openstack/compute/v2/volumeattach_test.go b/acceptance/openstack/compute/v2/volumeattach_test.go
new file mode 100644
index 0000000..459d283
--- /dev/null
+++ b/acceptance/openstack/compute/v2/volumeattach_test.go
@@ -0,0 +1,125 @@
+// +build acceptance compute servers
+
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/volumeattach"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func newBlockClient(t *testing.T) (*gophercloud.ServiceClient, error) {
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := openstack.AuthenticatedClient(ao)
+	th.AssertNoErr(t, err)
+
+	return openstack.NewBlockStorageV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func createVAServer(t *testing.T, computeClient *gophercloud.ServiceClient, choices *ComputeChoices) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	server, err := servers.Create(computeClient, servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		AdminPass: pwd,
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func createVAVolume(t *testing.T, blockClient *gophercloud.ServiceClient) (*volumes.Volume, error) {
+	volume, err := volumes.Create(blockClient, &volumes.CreateOpts{
+		Size: 1,
+		Name: "gophercloud-test-volume",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	defer func() {
+		err = volumes.WaitForStatus(blockClient, volume.ID, "available", 60)
+		th.AssertNoErr(t, err)
+	}()
+
+	return volume, err
+}
+
+func createVolumeAttachment(t *testing.T, computeClient *gophercloud.ServiceClient, blockClient *gophercloud.ServiceClient, serverId string, volumeId string) {
+	va, err := volumeattach.Create(computeClient, serverId, &volumeattach.CreateOpts{
+		VolumeID: volumeId,
+	}).Extract()
+	th.AssertNoErr(t, err)
+	defer func() {
+		err = volumes.WaitForStatus(blockClient, volumeId, "in-use", 60)
+		th.AssertNoErr(t, err)
+		err = volumeattach.Delete(computeClient, serverId, va.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		err = volumes.WaitForStatus(blockClient, volumeId, "available", 60)
+		th.AssertNoErr(t, err)
+	}()
+}
+
+func TestAttachVolume(t *testing.T) {
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	computeClient, err := newClient()
+	if err != nil {
+		t.Fatalf("Unable to create a compute client: %v", err)
+	}
+
+	blockClient, err := newBlockClient(t)
+	if err != nil {
+		t.Fatalf("Unable to create a blockstorage client: %v", err)
+	}
+
+	server, err := createVAServer(t, computeClient, choices)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(computeClient, server.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(computeClient, server, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	volume, err := createVAVolume(t, blockClient)
+	if err != nil {
+		t.Fatalf("Unable to create volume: %v", err)
+	}
+	defer func() {
+		err = volumes.Delete(blockClient, volume.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Volume deleted.")
+	}()
+
+	createVolumeAttachment(t, computeClient, blockClient, server.ID, volume.ID)
+
+}
diff --git a/acceptance/openstack/db/v1/common.go b/acceptance/openstack/db/v1/common.go
new file mode 100644
index 0000000..bbe7ebd
--- /dev/null
+++ b/acceptance/openstack/db/v1/common.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := openstack.AuthenticatedClient(ao)
+	th.AssertNoErr(t, err)
+
+	c, err := openstack.NewDBV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+	th.AssertNoErr(t, err)
+
+	return c
+}
+
+type context struct {
+	test       *testing.T
+	client     *gophercloud.ServiceClient
+	instanceID string
+	DBIDs      []string
+	users      []string
+}
+
+func newContext(t *testing.T) context {
+	return context{
+		test:   t,
+		client: newClient(t),
+	}
+}
+
+func (c context) Logf(msg string, args ...interface{}) {
+	if len(args) > 0 {
+		c.test.Logf(msg, args...)
+	} else {
+		c.test.Log(msg)
+	}
+}
+
+func (c context) AssertNoErr(err error) {
+	th.AssertNoErr(c.test, err)
+}
+
+func (c context) WaitUntilActive(id string) {
+	err := gophercloud.WaitFor(60, func() (bool, error) {
+		inst, err := instances.Get(c.client, id).Extract()
+		if err != nil {
+			return false, err
+		}
+		if inst.Status == "ACTIVE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	c.AssertNoErr(err)
+}
diff --git a/acceptance/openstack/db/v1/database_test.go b/acceptance/openstack/db/v1/database_test.go
new file mode 100644
index 0000000..c52357a
--- /dev/null
+++ b/acceptance/openstack/db/v1/database_test.go
@@ -0,0 +1,45 @@
+// +build acceptance db
+
+package v1
+
+import (
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func (c context) createDBs() {
+	opts := db.BatchCreateOpts{
+		db.CreateOpts{Name: "db1"},
+		db.CreateOpts{Name: "db2"},
+		db.CreateOpts{Name: "db3"},
+	}
+
+	err := db.Create(c.client, c.instanceID, opts).ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Created three databases on instance %s: db1, db2, db3", c.instanceID)
+}
+
+func (c context) listDBs() {
+	c.Logf("Listing databases on instance %s", c.instanceID)
+
+	err := db.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+		dbList, err := db.ExtractDBs(page)
+		c.AssertNoErr(err)
+
+		for _, db := range dbList {
+			c.Logf("DB: %#v", db)
+		}
+
+		return true, nil
+	})
+
+	c.AssertNoErr(err)
+}
+
+func (c context) deleteDBs() {
+	for _, id := range []string{"db1", "db2", "db3"} {
+		err := db.Delete(c.client, c.instanceID, id).ExtractErr()
+		c.AssertNoErr(err)
+		c.Logf("Deleted DB %s", id)
+	}
+}
diff --git a/acceptance/openstack/db/v1/flavor_test.go b/acceptance/openstack/db/v1/flavor_test.go
new file mode 100644
index 0000000..6440cc9
--- /dev/null
+++ b/acceptance/openstack/db/v1/flavor_test.go
@@ -0,0 +1,31 @@
+// +build acceptance db
+
+package v1
+
+import (
+	"github.com/gophercloud/gophercloud/openstack/db/v1/flavors"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func (c context) listFlavors() {
+	c.Logf("Listing flavors")
+
+	err := flavors.List(c.client).EachPage(func(page pagination.Page) (bool, error) {
+		flavorList, err := flavors.ExtractFlavors(page)
+		c.AssertNoErr(err)
+
+		for _, f := range flavorList {
+			c.Logf("Flavor: ID [%s] Name [%s] RAM [%d]", f.ID, f.Name, f.RAM)
+		}
+
+		return true, nil
+	})
+
+	c.AssertNoErr(err)
+}
+
+func (c context) getFlavor() {
+	flavor, err := flavors.Get(c.client, "1").Extract()
+	c.Logf("Getting flavor %s", flavor.ID)
+	c.AssertNoErr(err)
+}
diff --git a/acceptance/openstack/db/v1/instance_test.go b/acceptance/openstack/db/v1/instance_test.go
new file mode 100644
index 0000000..75668a2
--- /dev/null
+++ b/acceptance/openstack/db/v1/instance_test.go
@@ -0,0 +1,138 @@
+// +build acceptance db
+
+package v1
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+const envDSType = "DATASTORE_TYPE_ID"
+
+func TestRunner(t *testing.T) {
+	c := newContext(t)
+
+	// FLAVOR tests
+	c.listFlavors()
+	c.getFlavor()
+
+	// INSTANCE tests
+	c.createInstance()
+	c.listInstances()
+	c.getInstance()
+	c.isRootEnabled()
+	c.enableRootUser()
+	c.isRootEnabled()
+	c.restartInstance()
+	//c.resizeInstance()
+	//c.resizeVol()
+
+	// DATABASE tests
+	c.createDBs()
+	c.listDBs()
+
+	// USER tests
+	c.createUsers()
+	c.listUsers()
+
+	// TEARDOWN
+	c.deleteUsers()
+	c.deleteDBs()
+	c.deleteInstance()
+}
+
+func (c context) createInstance() {
+	if os.Getenv(envDSType) == "" {
+		c.test.Fatalf("%s must be set as an environment var", envDSType)
+	}
+
+	opts := instances.CreateOpts{
+		FlavorRef: "2",
+		Size:      5,
+		Name:      tools.RandomString("gopher_db", 5),
+		Datastore: &instances.DatastoreOpts{Type: os.Getenv(envDSType)},
+	}
+
+	instance, err := instances.Create(c.client, opts).Extract()
+	th.AssertNoErr(c.test, err)
+
+	c.Logf("Restarting %s. Waiting...", instance.ID)
+	c.WaitUntilActive(instance.ID)
+	c.Logf("Created Instance %s", instance.ID)
+
+	c.instanceID = instance.ID
+}
+
+func (c context) listInstances() {
+	c.Logf("Listing instances")
+
+	err := instances.List(c.client).EachPage(func(page pagination.Page) (bool, error) {
+		instanceList, err := instances.ExtractInstances(page)
+		c.AssertNoErr(err)
+
+		for _, i := range instanceList {
+			c.Logf("Instance: ID [%s] Name [%s] Status [%s] VolSize [%d] Datastore Type [%s]",
+				i.ID, i.Name, i.Status, i.Volume.Size, i.Datastore.Type)
+		}
+
+		return true, nil
+	})
+
+	c.AssertNoErr(err)
+}
+
+func (c context) getInstance() {
+	instance, err := instances.Get(c.client, c.instanceID).Extract()
+	c.AssertNoErr(err)
+	c.Logf("Getting instance: %s", instance.ID)
+}
+
+func (c context) deleteInstance() {
+	err := instances.Delete(c.client, c.instanceID).ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Deleted instance %s", c.instanceID)
+}
+
+func (c context) enableRootUser() {
+	_, err := instances.EnableRootUser(c.client, c.instanceID).Extract()
+	c.AssertNoErr(err)
+	c.Logf("Enabled root user on %s", c.instanceID)
+}
+
+func (c context) isRootEnabled() {
+	enabled, err := instances.IsRootEnabled(c.client, c.instanceID)
+	c.AssertNoErr(err)
+	c.Logf("Is root enabled? %d", enabled)
+}
+
+func (c context) restartInstance() {
+	id := c.instanceID
+	err := instances.Restart(c.client, id).ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Restarting %s. Waiting...", id)
+	c.WaitUntilActive(id)
+	c.Logf("Restarted %s", id)
+}
+
+func (c context) resizeInstance() {
+	id := c.instanceID
+	err := instances.Resize(c.client, id, "3").ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Resizing %s. Waiting...", id)
+	c.WaitUntilActive(id)
+	c.Logf("Resized %s with flavorRef %s", id, "2")
+}
+
+func (c context) resizeVol() {
+	id := c.instanceID
+	err := instances.ResizeVolume(c.client, id, 4).ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Resizing volume of %s. Waiting...", id)
+	c.WaitUntilActive(id)
+	c.Logf("Resized the volume of %s to %d GB", id, 2)
+}
diff --git a/acceptance/openstack/db/v1/pkg.go b/acceptance/openstack/db/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/db/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/db/v1/user_test.go b/acceptance/openstack/db/v1/user_test.go
new file mode 100644
index 0000000..0f5fcc2
--- /dev/null
+++ b/acceptance/openstack/db/v1/user_test.go
@@ -0,0 +1,70 @@
+// +build acceptance db
+
+package v1
+
+import (
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	u "github.com/gophercloud/gophercloud/openstack/db/v1/users"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func (c context) createUsers() {
+	users := []string{
+		tools.RandomString("user_", 5),
+		tools.RandomString("user_", 5),
+		tools.RandomString("user_", 5),
+	}
+
+	db1 := db.CreateOpts{Name: "db1"}
+	db2 := db.CreateOpts{Name: "db2"}
+	db3 := db.CreateOpts{Name: "db3"}
+
+	opts := u.BatchCreateOpts{
+		u.CreateOpts{
+			Name:      users[0],
+			Password:  tools.RandomString("", 5),
+			Databases: db.BatchCreateOpts{db1, db2, db3},
+		},
+		u.CreateOpts{
+			Name:      users[1],
+			Password:  tools.RandomString("", 5),
+			Databases: db.BatchCreateOpts{db1, db2},
+		},
+		u.CreateOpts{
+			Name:      users[2],
+			Password:  tools.RandomString("", 5),
+			Databases: db.BatchCreateOpts{db3},
+		},
+	}
+
+	err := u.Create(c.client, c.instanceID, opts).ExtractErr()
+	c.AssertNoErr(err)
+	c.Logf("Created three users on instance %s: %s, %s, %s", c.instanceID, users[0], users[1], users[2])
+	c.users = users
+}
+
+func (c context) listUsers() {
+	c.Logf("Listing databases on instance %s", c.instanceID)
+
+	err := db.List(c.client, c.instanceID).EachPage(func(page pagination.Page) (bool, error) {
+		dbList, err := db.ExtractDBs(page)
+		c.AssertNoErr(err)
+
+		for _, db := range dbList {
+			c.Logf("DB: %#v", db)
+		}
+
+		return true, nil
+	})
+
+	c.AssertNoErr(err)
+}
+
+func (c context) deleteUsers() {
+	for _, id := range c.DBIDs {
+		err := db.Delete(c.client, c.instanceID, id).ExtractErr()
+		c.AssertNoErr(err)
+		c.Logf("Deleted DB %s", id)
+	}
+}
diff --git a/acceptance/openstack/identity/v2/extension_test.go b/acceptance/openstack/identity/v2/extension_test.go
new file mode 100644
index 0000000..23627dc
--- /dev/null
+++ b/acceptance/openstack/identity/v2/extension_test.go
@@ -0,0 +1,46 @@
+// +build acceptance identity
+
+package v2
+
+import (
+	"testing"
+
+	extensions2 "github.com/gophercloud/gophercloud/openstack/identity/v2/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestEnumerateExtensions(t *testing.T) {
+	service := authenticatedClient(t)
+
+	t.Logf("Extensions available on this identity endpoint:")
+	count := 0
+	err := extensions2.List(service).EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page %02d ---", count)
+
+		extensions, err := extensions2.ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		for i, ext := range extensions {
+			t.Logf("[%02d] name=[%s] namespace=[%s]", i, ext.Name, ext.Namespace)
+			t.Logf("     alias=[%s] updated=[%s]", ext.Alias, ext.Updated)
+			t.Logf("     description=[%s]", ext.Description)
+		}
+
+		count++
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
+
+func TestGetExtension(t *testing.T) {
+	service := authenticatedClient(t)
+
+	ext, err := extensions2.Get(service, "OS-KSCRUD").Extract()
+	th.AssertNoErr(t, err)
+
+	th.CheckEquals(t, "OpenStack Keystone User CRUD", ext.Name)
+	th.CheckEquals(t, "http://docs.openstack.org/identity/api/ext/OS-KSCRUD/v1.0", ext.Namespace)
+	th.CheckEquals(t, "OS-KSCRUD", ext.Alias)
+	th.CheckEquals(t, "OpenStack extensions to Keystone v2.0 API enabling User Operations.", ext.Description)
+}
diff --git a/acceptance/openstack/identity/v2/identity_test.go b/acceptance/openstack/identity/v2/identity_test.go
new file mode 100644
index 0000000..e0e2c0e
--- /dev/null
+++ b/acceptance/openstack/identity/v2/identity_test.go
@@ -0,0 +1,47 @@
+// +build acceptance identity
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func v2AuthOptions(t *testing.T) gophercloud.AuthOptions {
+	// Obtain credentials from the environment.
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	// Trim out unused fields. Prefer authentication by API key to password.
+	ao.UserID, ao.DomainID, ao.DomainName = "", "", ""
+	if ao.APIKey != "" {
+		ao.Password = ""
+	}
+
+	return ao
+}
+
+func createClient(t *testing.T, auth bool) *gophercloud.ServiceClient {
+	ao := v2AuthOptions(t)
+
+	provider, err := openstack.NewClient(ao.IdentityEndpoint)
+	th.AssertNoErr(t, err)
+
+	if auth {
+		err = openstack.AuthenticateV2(provider, ao)
+		th.AssertNoErr(t, err)
+	}
+
+	return openstack.NewIdentityV2(provider)
+}
+
+func unauthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	return createClient(t, false)
+}
+
+func authenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	return createClient(t, true)
+}
diff --git a/acceptance/openstack/identity/v2/pkg.go b/acceptance/openstack/identity/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/identity/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/identity/v2/role_test.go b/acceptance/openstack/identity/v2/role_test.go
new file mode 100644
index 0000000..3f47858
--- /dev/null
+++ b/acceptance/openstack/identity/v2/role_test.go
@@ -0,0 +1,58 @@
+// +build acceptance identity roles
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/identity/v2/extensions/admin/roles"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestRoles(t *testing.T) {
+	client := authenticatedClient(t)
+
+	tenantID := findTenant(t, client)
+	userID := createUser(t, client, tenantID)
+	roleID := listRoles(t, client)
+
+	addUserRole(t, client, tenantID, userID, roleID)
+
+	deleteUserRole(t, client, tenantID, userID, roleID)
+
+	deleteUser(t, client, userID)
+}
+
+func listRoles(t *testing.T, client *gophercloud.ServiceClient) string {
+	var roleID string
+
+	err := roles.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		roleList, err := roles.ExtractRoles(page)
+		th.AssertNoErr(t, err)
+
+		for _, role := range roleList {
+			t.Logf("Listing role: ID [%s] Name [%s]", role.ID, role.Name)
+			roleID = role.ID
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+
+	return roleID
+}
+
+func addUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+	err := roles.AddUserRole(client, tenantID, userID, roleID).ExtractErr()
+	th.AssertNoErr(t, err)
+	t.Logf("Added role %s to user %s", roleID, userID)
+}
+
+func deleteUserRole(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID, roleID string) {
+	err := roles.DeleteUserRole(client, tenantID, userID, roleID).ExtractErr()
+	th.AssertNoErr(t, err)
+	t.Logf("Removed role %s from user %s", roleID, userID)
+}
diff --git a/acceptance/openstack/identity/v2/tenant_test.go b/acceptance/openstack/identity/v2/tenant_test.go
new file mode 100644
index 0000000..5f7440d
--- /dev/null
+++ b/acceptance/openstack/identity/v2/tenant_test.go
@@ -0,0 +1,32 @@
+// +build acceptance identity
+
+package v2
+
+import (
+	"testing"
+
+	tenants2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestEnumerateTenants(t *testing.T) {
+	service := authenticatedClient(t)
+
+	t.Logf("Tenants to which your current token grants access:")
+	count := 0
+	err := tenants2.List(service, nil).EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page %02d ---", count)
+
+		tenants, err := tenants2.ExtractTenants(page)
+		th.AssertNoErr(t, err)
+		for i, tenant := range tenants {
+			t.Logf("[%02d] name=[%s] id=[%s] description=[%s] enabled=[%v]",
+				i, tenant.Name, tenant.ID, tenant.Description, tenant.Enabled)
+		}
+
+		count++
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/identity/v2/token_test.go b/acceptance/openstack/identity/v2/token_test.go
new file mode 100644
index 0000000..c2f7e51
--- /dev/null
+++ b/acceptance/openstack/identity/v2/token_test.go
@@ -0,0 +1,54 @@
+// +build acceptance identity
+
+package v2
+
+import (
+	"testing"
+
+	tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAuthenticateAndValidate(t *testing.T) {
+	// 1. TestAuthenticate
+	ao := v2AuthOptions(t)
+	service := unauthenticatedClient(t)
+
+	// Authenticated!
+	result := tokens2.Create(service, tokens2.WrapOptions(ao))
+
+	// Extract and print the token.
+	token, err := result.ExtractToken()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Acquired token: [%s]", token.ID)
+	t.Logf("The token will expire at: [%s]", token.ExpiresAt.String())
+	t.Logf("The token is valid for tenant: [%#v]", token.Tenant)
+
+	// Extract and print the service catalog.
+	catalog, err := result.ExtractServiceCatalog()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Acquired service catalog listing [%d] services", len(catalog.Entries))
+	for i, entry := range catalog.Entries {
+		t.Logf("[%02d]: name=[%s], type=[%s]", i, entry.Name, entry.Type)
+		for _, endpoint := range entry.Endpoints {
+			t.Logf("      - region=[%s] publicURL=[%s]", endpoint.Region, endpoint.PublicURL)
+		}
+	}
+
+	// 2. TestValidate
+	client := authenticatedClient(t)
+
+	// Validate Token!
+	getResult := tokens2.Get(client, token.ID)
+
+	// Extract and print the user.
+	user, err := getResult.ExtractUser()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Acquired User: [%s]", user.Name)
+	t.Logf("The User id: [%s]", user.ID)
+	t.Logf("The User username: [%s]", user.UserName)
+	t.Logf("The User roles: [%#v]", user.Roles)
+}
diff --git a/acceptance/openstack/identity/v2/user_test.go b/acceptance/openstack/identity/v2/user_test.go
new file mode 100644
index 0000000..7938b37
--- /dev/null
+++ b/acceptance/openstack/identity/v2/user_test.go
@@ -0,0 +1,127 @@
+// +build acceptance identity
+
+package v2
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+	"github.com/gophercloud/gophercloud/openstack/identity/v2/users"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestUsers(t *testing.T) {
+	client := authenticatedClient(t)
+
+	tenantID := findTenant(t, client)
+
+	userID := createUser(t, client, tenantID)
+
+	listUsers(t, client)
+
+	getUser(t, client, userID)
+
+	updateUser(t, client, userID)
+
+	listUserRoles(t, client, tenantID, userID)
+
+	deleteUser(t, client, userID)
+}
+
+func findTenant(t *testing.T, client *gophercloud.ServiceClient) string {
+	var tenantID string
+	err := tenants.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+		tenantList, err := tenants.ExtractTenants(page)
+		th.AssertNoErr(t, err)
+
+		for _, t := range tenantList {
+			tenantID = t.ID
+			break
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	return tenantID
+}
+
+func createUser(t *testing.T, client *gophercloud.ServiceClient, tenantID string) string {
+	t.Log("Creating user")
+
+	opts := users.CreateOpts{
+		Name:     tools.RandomString("user_", 5),
+		Enabled:  users.Disabled,
+		TenantID: tenantID,
+		Email:    "new_user@foo.com",
+	}
+
+	user, err := users.Create(client, opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created user %s on tenant %s", user.ID, tenantID)
+
+	return user.ID
+}
+
+func listUsers(t *testing.T, client *gophercloud.ServiceClient) {
+	err := users.List(client).EachPage(func(page pagination.Page) (bool, error) {
+		userList, err := users.ExtractUsers(page)
+		th.AssertNoErr(t, err)
+
+		for _, user := range userList {
+			t.Logf("Listing user: ID [%s] Name [%s] Email [%s] Enabled? [%s]",
+				user.ID, user.Name, user.Email, strconv.FormatBool(user.Enabled))
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func getUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+	_, err := users.Get(client, userID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting user %s", userID)
+}
+
+func updateUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+	opts := users.UpdateOpts{Name: tools.RandomString("new_name", 5), Email: "new@foo.com"}
+	user, err := users.Update(client, userID, opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Updated user %s: Name [%s] Email [%s]", userID, user.Name, user.Email)
+}
+
+func listUserRoles(t *testing.T, client *gophercloud.ServiceClient, tenantID, userID string) {
+	count := 0
+	err := users.ListRoles(client, tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+
+		roleList, err := users.ExtractRoles(page)
+		th.AssertNoErr(t, err)
+
+		t.Logf("Listing roles for user %s", userID)
+
+		for _, r := range roleList {
+			t.Logf("- %s (%s)", r.Name, r.ID)
+		}
+
+		return true, nil
+	})
+
+	if count == 0 {
+		t.Logf("No roles for user %s", userID)
+	}
+
+	th.AssertNoErr(t, err)
+}
+
+func deleteUser(t *testing.T, client *gophercloud.ServiceClient, userID string) {
+	res := users.Delete(client, userID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted user %s", userID)
+}
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
new file mode 100644
index 0000000..f575649
--- /dev/null
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -0,0 +1,111 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	endpoints3 "github.com/gophercloud/gophercloud/openstack/identity/v3/endpoints"
+	services3 "github.com/gophercloud/gophercloud/openstack/identity/v3/services"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func TestListEndpoints(t *testing.T) {
+	// Create a service client.
+	serviceClient := createAuthenticatedClient(t)
+	if serviceClient == nil {
+		return
+	}
+
+	// Use the service to list all available endpoints.
+	pager := endpoints3.List(serviceClient, endpoints3.ListOpts{})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		endpoints, err := endpoints3.ExtractEndpoints(page)
+		if err != nil {
+			t.Fatalf("Error extracting endpoings: %v", err)
+		}
+
+		for _, endpoint := range endpoints {
+			t.Logf("Endpoint: %8s %10s %9s %s",
+				endpoint.ID,
+				endpoint.Availability,
+				endpoint.Name,
+				endpoint.URL)
+		}
+
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("Unexpected error while iterating endpoint pages: %v", err)
+	}
+}
+
+func TestNavigateCatalog(t *testing.T) {
+	// Create a service client.
+	client := createAuthenticatedClient(t)
+	if client == nil {
+		return
+	}
+
+	var compute *services3.Service
+	var endpoint *endpoints3.Endpoint
+
+	// Discover the service we're interested in.
+	servicePager := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+	err := servicePager.EachPage(func(page pagination.Page) (bool, error) {
+		part, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+		if compute != nil {
+			t.Fatalf("Expected one service, got more than one page")
+			return false, nil
+		}
+		if len(part) != 1 {
+			t.Fatalf("Expected one service, got %d", len(part))
+			return false, nil
+		}
+
+		compute = &part[0]
+		return true, nil
+	})
+	if err != nil {
+		t.Fatalf("Unexpected error iterating pages: %v", err)
+	}
+
+	if compute == nil {
+		t.Fatalf("No compute service found.")
+	}
+
+	// Enumerate the endpoints available for this service.
+	computePager := endpoints3.List(client, endpoints3.ListOpts{
+		Availability: gophercloud.AvailabilityPublic,
+		ServiceID:    compute.ID,
+	})
+	err = computePager.EachPage(func(page pagination.Page) (bool, error) {
+		part, err := endpoints3.ExtractEndpoints(page)
+		if err != nil {
+			return false, err
+		}
+		if endpoint != nil {
+			t.Fatalf("Expected one endpoint, got more than one page")
+			return false, nil
+		}
+		if len(part) != 1 {
+			t.Fatalf("Expected one endpoint, got %d", len(part))
+			return false, nil
+		}
+
+		endpoint = &part[0]
+		return true, nil
+	})
+
+	if endpoint == nil {
+		t.Fatalf("No endpoint found.")
+	}
+
+	t.Logf("Success. The compute endpoint is at %s.", endpoint.URL)
+}
diff --git a/acceptance/openstack/identity/v3/identity_test.go b/acceptance/openstack/identity/v3/identity_test.go
new file mode 100644
index 0000000..6974ad0
--- /dev/null
+++ b/acceptance/openstack/identity/v3/identity_test.go
@@ -0,0 +1,39 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func createAuthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	// Obtain credentials from the environment.
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	// Trim out unused fields.
+	ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+
+	if ao.UserID == "" {
+		t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+		return nil
+	}
+
+	// Create a client and manually authenticate against v3.
+	providerClient, err := openstack.NewClient(ao.IdentityEndpoint)
+	if err != nil {
+		t.Fatalf("Unable to instantiate client: %v", err)
+	}
+
+	err = openstack.AuthenticateV3(providerClient, ao)
+	if err != nil {
+		t.Fatalf("Unable to authenticate against identity v3: %v", err)
+	}
+
+	// Create a service client.
+	return openstack.NewIdentityV3(providerClient)
+}
diff --git a/acceptance/openstack/identity/v3/pkg.go b/acceptance/openstack/identity/v3/pkg.go
new file mode 100644
index 0000000..eac3ae9
--- /dev/null
+++ b/acceptance/openstack/identity/v3/pkg.go
@@ -0,0 +1 @@
+package v3
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
new file mode 100644
index 0000000..b39ba7f
--- /dev/null
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -0,0 +1,36 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	services3 "github.com/gophercloud/gophercloud/openstack/identity/v3/services"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func TestListServices(t *testing.T) {
+	// Create a service client.
+	serviceClient := createAuthenticatedClient(t)
+	if serviceClient == nil {
+		return
+	}
+
+	// Use the client to list all available services.
+	pager := services3.List(serviceClient, services3.ListOpts{})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		parts, err := services3.ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+
+		t.Logf("--- Page ---")
+		for _, service := range parts {
+			t.Logf("Service: %32s %15s %10s %s", service.ID, service.Type, service.Name, *service.Description)
+		}
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("Unexpected error traversing pages: %v", err)
+	}
+}
diff --git a/acceptance/openstack/identity/v3/token_test.go b/acceptance/openstack/identity/v3/token_test.go
new file mode 100644
index 0000000..5340ead
--- /dev/null
+++ b/acceptance/openstack/identity/v3/token_test.go
@@ -0,0 +1,42 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack"
+	tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+)
+
+func TestGetToken(t *testing.T) {
+	// Obtain credentials from the environment.
+	ao, err := openstack.AuthOptionsFromEnv()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	// Trim out unused fields. Skip if we don't have a UserID.
+	ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+	if ao.UserID == "" {
+		t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+		return
+	}
+
+	// Create an unauthenticated client.
+	provider, err := openstack.NewClient(ao.IdentityEndpoint)
+	if err != nil {
+		t.Fatalf("Unable to instantiate client: %v", err)
+	}
+
+	// Create a service client.
+	service := openstack.NewIdentityV3(provider)
+
+	// Use the service to create a token.
+	token, err := tokens3.Create(service, ao, nil).Extract()
+	if err != nil {
+		t.Fatalf("Unable to get token: %v", err)
+	}
+
+	t.Logf("Acquired token: %s", token.ID)
+}
diff --git a/acceptance/openstack/networking/v2/apiversion_test.go b/acceptance/openstack/networking/v2/apiversion_test.go
new file mode 100644
index 0000000..22827d6
--- /dev/null
+++ b/acceptance/openstack/networking/v2/apiversion_test.go
@@ -0,0 +1,51 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/apiversions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListAPIVersions(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := apiversions.ListVersions(Client)
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		versions, err := apiversions.ExtractAPIVersions(page)
+		th.AssertNoErr(t, err)
+
+		for _, v := range versions {
+			t.Logf("API Version: ID [%s] Status [%s]", v.ID, v.Status)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestListAPIResources(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := apiversions.ListVersionResources(Client, "v2.0")
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		vrs, err := apiversions.ExtractVersionResources(page)
+		th.AssertNoErr(t, err)
+
+		for _, vr := range vrs {
+			t.Logf("Network: Name [%s] Collection [%s]", vr.Name, vr.Collection)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
diff --git a/acceptance/openstack/networking/v2/common.go b/acceptance/openstack/networking/v2/common.go
new file mode 100644
index 0000000..b48855b
--- /dev/null
+++ b/acceptance/openstack/networking/v2/common.go
@@ -0,0 +1,39 @@
+package v2
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+var Client *gophercloud.ServiceClient
+
+func NewClient() (*gophercloud.ServiceClient, error) {
+	opts, err := openstack.AuthOptionsFromEnv()
+	if err != nil {
+		return nil, err
+	}
+
+	provider, err := openstack.AuthenticatedClient(opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return openstack.NewNetworkV2(provider, gophercloud.EndpointOpts{
+		Name:   "neutron",
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+}
+
+func Setup(t *testing.T) {
+	client, err := NewClient()
+	th.AssertNoErr(t, err)
+	Client = client
+}
+
+func Teardown() {
+	Client = nil
+}
diff --git a/acceptance/openstack/networking/v2/extension_test.go b/acceptance/openstack/networking/v2/extension_test.go
new file mode 100644
index 0000000..e125034
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extension_test.go
@@ -0,0 +1,45 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestListExts(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := extensions.List(Client)
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		exts, err := extensions.ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		for _, ext := range exts {
+			t.Logf("Extension: Name [%s] Description [%s]", ext.Name, ext.Description)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestGetExt(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	ext, err := extensions.Get(Client, "service-type").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, ext.Updated, "2013-01-20T00:00:00-00:00")
+	th.AssertEquals(t, ext.Name, "Neutron Service Type Management")
+	th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/neutron/service-type/api/v1.0")
+	th.AssertEquals(t, ext.Alias, "service-type")
+	th.AssertEquals(t, ext.Description, "API for retrieving service providers for Neutron advanced services")
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go
new file mode 100644
index 0000000..ef1fb1a
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/firewall_test.go
@@ -0,0 +1,116 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/firewalls"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func firewallSetup(t *testing.T) string {
+	base.Setup(t)
+	return createPolicy(t, &policies.CreateOpts{})
+}
+
+func firewallTeardown(t *testing.T, policyID string) {
+	defer base.Teardown()
+	deletePolicy(t, policyID)
+}
+
+func TestFirewall(t *testing.T) {
+	policyID := firewallSetup(t)
+	defer firewallTeardown(t, policyID)
+
+	firewallID := createFirewall(t, &firewalls.CreateOpts{
+		Name:        "gophercloud test",
+		Description: "acceptance test",
+		PolicyID:    policyID,
+	})
+
+	waitForFirewallToBeActive(t, firewallID)
+
+	listFirewalls(t)
+
+	updateFirewall(t, firewallID, &firewalls.UpdateOpts{
+		Description: "acceptance test updated",
+	})
+
+	waitForFirewallToBeActive(t, firewallID)
+
+	deleteFirewall(t, firewallID)
+
+	waitForFirewallToBeDeleted(t, firewallID)
+}
+
+func createFirewall(t *testing.T, opts *firewalls.CreateOpts) string {
+	f, err := firewalls.Create(base.Client, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created firewall: %#v", opts)
+	return f.ID
+}
+
+func listFirewalls(t *testing.T) {
+	err := firewalls.List(base.Client, firewalls.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		firewallList, err := firewalls.ExtractFirewalls(page)
+		if err != nil {
+			t.Errorf("Failed to extract firewalls: %v", err)
+			return false, err
+		}
+
+		for _, r := range firewallList {
+			t.Logf("Listing firewalls: ID [%s]", r.ID)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
+
+func updateFirewall(t *testing.T, firewallID string, opts *firewalls.UpdateOpts) {
+	f, err := firewalls.Update(base.Client, firewallID, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Updated firewall ID [%s]", f.ID)
+}
+
+func getFirewall(t *testing.T, firewallID string) *firewalls.Firewall {
+	f, err := firewalls.Get(base.Client, firewallID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting firewall ID [%s]", f.ID)
+	return f
+}
+
+func deleteFirewall(t *testing.T, firewallID string) {
+	res := firewalls.Delete(base.Client, firewallID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted firewall %s", firewallID)
+}
+
+func waitForFirewallToBeActive(t *testing.T, firewallID string) {
+	for i := 0; i < 10; i++ {
+		fw := getFirewall(t, firewallID)
+		if fw.Status == "ACTIVE" {
+			break
+		}
+		time.Sleep(time.Second)
+	}
+}
+
+func waitForFirewallToBeDeleted(t *testing.T, firewallID string) {
+	for i := 0; i < 10; i++ {
+		err := firewalls.Get(base.Client, firewallID).Err
+		if err != nil {
+			httpStatus := err.(*gophercloud.UnexpectedResponseCodeError)
+			if httpStatus.Actual == 404 {
+				return
+			}
+		}
+		time.Sleep(time.Second)
+	}
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go b/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go
new file mode 100644
index 0000000..206bf33
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/pkg.go
@@ -0,0 +1 @@
+package fwaas
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go
new file mode 100644
index 0000000..84bae52
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/policy_test.go
@@ -0,0 +1,107 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/policies"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func firewallPolicySetup(t *testing.T) string {
+	base.Setup(t)
+	return createRule(t, &rules.CreateOpts{
+		Protocol: "tcp",
+		Action:   "allow",
+	})
+}
+
+func firewallPolicyTeardown(t *testing.T, ruleID string) {
+	defer base.Teardown()
+	deleteRule(t, ruleID)
+}
+
+func TestFirewallPolicy(t *testing.T) {
+	ruleID := firewallPolicySetup(t)
+	defer firewallPolicyTeardown(t, ruleID)
+
+	policyID := createPolicy(t, &policies.CreateOpts{
+		Name:        "gophercloud test",
+		Description: "acceptance test",
+		Rules: []string{
+			ruleID,
+		},
+	})
+
+	listPolicies(t)
+
+	updatePolicy(t, policyID, &policies.UpdateOpts{
+		Description: "acceptance test updated",
+	})
+
+	getPolicy(t, policyID)
+
+	removeRuleFromPolicy(t, policyID, ruleID)
+
+	addRuleToPolicy(t, policyID, ruleID)
+
+	deletePolicy(t, policyID)
+}
+
+func createPolicy(t *testing.T, opts *policies.CreateOpts) string {
+	p, err := policies.Create(base.Client, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created policy: %#v", opts)
+	return p.ID
+}
+
+func listPolicies(t *testing.T) {
+	err := policies.List(base.Client, policies.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		policyList, err := policies.ExtractPolicies(page)
+		if err != nil {
+			t.Errorf("Failed to extract policies: %v", err)
+			return false, err
+		}
+
+		for _, p := range policyList {
+			t.Logf("Listing policies: ID [%s]", p.ID)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
+
+func updatePolicy(t *testing.T, policyID string, opts *policies.UpdateOpts) {
+	p, err := policies.Update(base.Client, policyID, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Updated policy ID [%s]", p.ID)
+}
+
+func removeRuleFromPolicy(t *testing.T, policyID string, ruleID string) {
+	err := policies.RemoveRule(base.Client, policyID, ruleID)
+	th.AssertNoErr(t, err)
+	t.Logf("Removed rule [%s] from policy ID [%s]", ruleID, policyID)
+}
+
+func addRuleToPolicy(t *testing.T, policyID string, ruleID string) {
+	err := policies.InsertRule(base.Client, policyID, ruleID, "", "")
+	th.AssertNoErr(t, err)
+	t.Logf("Inserted rule [%s] into policy ID [%s]", ruleID, policyID)
+}
+
+func getPolicy(t *testing.T, policyID string) {
+	p, err := policies.Get(base.Client, policyID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting policy ID [%s]", p.ID)
+}
+
+func deletePolicy(t *testing.T, policyID string) {
+	res := policies.Delete(base.Client, policyID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted policy %s", policyID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go b/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go
new file mode 100644
index 0000000..aa11ec6
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/fwaas/rule_test.go
@@ -0,0 +1,84 @@
+// +build acceptance networking fwaas
+
+package fwaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/fwaas/rules"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestFirewallRules(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	ruleID := createRule(t, &rules.CreateOpts{
+		Name:                 "gophercloud_test",
+		Description:          "acceptance test",
+		Protocol:             "tcp",
+		Action:               "allow",
+		DestinationIPAddress: "192.168.0.0/24",
+		DestinationPort:      "22",
+	})
+
+	listRules(t)
+
+	destinationIPAddress := "192.168.1.0/24"
+	destinationPort := ""
+	sourcePort := "1234"
+
+	updateRule(t, ruleID, &rules.UpdateOpts{
+		DestinationIPAddress: &destinationIPAddress,
+		DestinationPort:      &destinationPort,
+		SourcePort:           &sourcePort,
+	})
+
+	getRule(t, ruleID)
+
+	deleteRule(t, ruleID)
+}
+
+func createRule(t *testing.T, opts *rules.CreateOpts) string {
+	r, err := rules.Create(base.Client, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created rule: %#v", opts)
+	return r.ID
+}
+
+func listRules(t *testing.T) {
+	err := rules.List(base.Client, rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		ruleList, err := rules.ExtractRules(page)
+		if err != nil {
+			t.Errorf("Failed to extract rules: %v", err)
+			return false, err
+		}
+
+		for _, r := range ruleList {
+			t.Logf("Listing rules: ID [%s]", r.ID)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+}
+
+func updateRule(t *testing.T, ruleID string, opts *rules.UpdateOpts) {
+	r, err := rules.Update(base.Client, ruleID, *opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Updated rule ID [%s]", r.ID)
+}
+
+func getRule(t *testing.T, ruleID string) {
+	r, err := rules.Get(base.Client, ruleID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting rule ID [%s]", r.ID)
+}
+
+func deleteRule(t *testing.T, ruleID string) {
+	res := rules.Delete(base.Client, ruleID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted rule %s", ruleID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/layer3_test.go b/acceptance/openstack/networking/v2/extensions/layer3_test.go
new file mode 100644
index 0000000..7d9dba3
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/layer3_test.go
@@ -0,0 +1,300 @@
+// +build acceptance networking layer3ext
+
+package extensions
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/external"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+const (
+	cidr1 = "10.0.0.1/24"
+	cidr2 = "20.0.0.1/24"
+)
+
+func TestAll(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	testRouter(t)
+	testFloatingIP(t)
+}
+
+func testRouter(t *testing.T) {
+	// Setup: Create network
+	networkID := createNetwork(t)
+
+	// Create router
+	routerID := createRouter(t, networkID)
+
+	// Lists routers
+	listRouters(t)
+
+	// Update router
+	updateRouter(t, routerID)
+
+	// Get router
+	getRouter(t, routerID)
+
+	// Create new subnet. Note: this subnet will be deleted when networkID is deleted
+	subnetID := createSubnet(t, networkID, cidr2)
+
+	// Add interface
+	addInterface(t, routerID, subnetID)
+
+	// Remove interface
+	removeInterface(t, routerID, subnetID)
+
+	// Delete router
+	deleteRouter(t, routerID)
+
+	// Cleanup
+	deleteNetwork(t, networkID)
+}
+
+func testFloatingIP(t *testing.T) {
+	// Setup external network
+	extNetworkID := createNetwork(t)
+
+	// Setup internal network, subnet and port
+	intNetworkID, subnetID, portID := createInternalTopology(t)
+
+	// Now the important part: we need to allow the external network to talk to
+	// the internal subnet. For this we need a router that has an interface to
+	// the internal subnet.
+	routerID := bridgeIntSubnetWithExtNetwork(t, extNetworkID, subnetID)
+
+	// Create floating IP
+	ipID := createFloatingIP(t, extNetworkID, portID)
+
+	// Get floating IP
+	getFloatingIP(t, ipID)
+
+	// Update floating IP
+	updateFloatingIP(t, ipID, portID)
+
+	// Delete floating IP
+	deleteFloatingIP(t, ipID)
+
+	// Remove the internal subnet interface
+	removeInterface(t, routerID, subnetID)
+
+	// Delete router and external network
+	deleteRouter(t, routerID)
+	deleteNetwork(t, extNetworkID)
+
+	// Delete internal port and network
+	deletePort(t, portID)
+	deleteNetwork(t, intNetworkID)
+}
+
+func createNetwork(t *testing.T) string {
+	t.Logf("Creating a network")
+
+	asu := true
+	opts := external.CreateOpts{
+		Parent:   networks.CreateOpts{Name: "sample_network", AdminStateUp: &asu},
+		External: true,
+	}
+	n, err := networks.Create(base.Client, opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	if n.ID == "" {
+		t.Fatalf("No ID returned when creating a network")
+	}
+
+	createSubnet(t, n.ID, cidr1)
+
+	t.Logf("Network created: ID [%s]", n.ID)
+
+	return n.ID
+}
+
+func deleteNetwork(t *testing.T, networkID string) {
+	t.Logf("Deleting network %s", networkID)
+	networks.Delete(base.Client, networkID)
+}
+
+func deletePort(t *testing.T, portID string) {
+	t.Logf("Deleting port %s", portID)
+	ports.Delete(base.Client, portID)
+}
+
+func createInternalTopology(t *testing.T) (string, string, string) {
+	t.Logf("Creating an internal network (for port)")
+	opts := networks.CreateOpts{Name: "internal_network"}
+	n, err := networks.Create(base.Client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	// A subnet is also needed
+	subnetID := createSubnet(t, n.ID, cidr2)
+
+	t.Logf("Creating an internal port on network %s", n.ID)
+	p, err := ports.Create(base.Client, ports.CreateOpts{
+		NetworkID: n.ID,
+		Name:      "fixed_internal_port",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	return n.ID, subnetID, p.ID
+}
+
+func bridgeIntSubnetWithExtNetwork(t *testing.T, networkID, subnetID string) string {
+	// Create router with external gateway info
+	routerID := createRouter(t, networkID)
+
+	// Add interface for internal subnet
+	addInterface(t, routerID, subnetID)
+
+	return routerID
+}
+
+func createSubnet(t *testing.T, networkID, cidr string) string {
+	t.Logf("Creating a subnet for network %s", networkID)
+
+	iFalse := false
+	s, err := subnets.Create(base.Client, subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       cidr,
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: &iFalse,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Subnet created: ID [%s]", s.ID)
+
+	return s.ID
+}
+
+func createRouter(t *testing.T, networkID string) string {
+	t.Logf("Creating a router for network %s", networkID)
+
+	asu := false
+	gwi := routers.GatewayInfo{NetworkID: networkID}
+	r, err := routers.Create(base.Client, routers.CreateOpts{
+		Name:         "foo_router",
+		AdminStateUp: &asu,
+		GatewayInfo:  &gwi,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	if r.ID == "" {
+		t.Fatalf("No ID returned when creating a router")
+	}
+
+	t.Logf("Router created: ID [%s]", r.ID)
+
+	return r.ID
+}
+
+func listRouters(t *testing.T) {
+	pager := routers.List(base.Client, routers.ListOpts{})
+
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		routerList, err := routers.ExtractRouters(page)
+		th.AssertNoErr(t, err)
+
+		for _, r := range routerList {
+			t.Logf("Listing router: ID [%s] Name [%s] Status [%s] GatewayInfo [%#v]",
+				r.ID, r.Name, r.Status, r.GatewayInfo)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updateRouter(t *testing.T, routerID string) {
+	_, err := routers.Update(base.Client, routerID, routers.UpdateOpts{
+		Name: "another_name",
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+}
+
+func getRouter(t *testing.T, routerID string) {
+	r, err := routers.Get(base.Client, routerID).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting router: ID [%s] Name [%s] Status [%s]", r.ID, r.Name, r.Status)
+}
+
+func addInterface(t *testing.T, routerID, subnetID string) {
+	ir, err := routers.AddInterface(base.Client, routerID, routers.InterfaceOpts{SubnetID: subnetID}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Interface added to router %s: SubnetID [%s] PortID [%s]", routerID, ir.SubnetID, ir.PortID)
+}
+
+func removeInterface(t *testing.T, routerID, subnetID string) {
+	ir, err := routers.RemoveInterface(base.Client, routerID, routers.InterfaceOpts{SubnetID: subnetID}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Interface %s removed from %s", ir.ID, routerID)
+}
+
+func deleteRouter(t *testing.T, routerID string) {
+	t.Logf("Deleting router %s", routerID)
+
+	res := routers.Delete(base.Client, routerID)
+
+	th.AssertNoErr(t, res.Err)
+}
+
+func createFloatingIP(t *testing.T, networkID, portID string) string {
+	t.Logf("Creating floating IP on network [%s] with port [%s]", networkID, portID)
+
+	opts := floatingips.CreateOpts{
+		FloatingNetworkID: networkID,
+		PortID:            portID,
+	}
+
+	ip, err := floatingips.Create(base.Client, opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Floating IP created: ID [%s] Status [%s] Fixed (internal) IP: [%s] Floating (external) IP: [%s]",
+		ip.ID, ip.Status, ip.FixedIP, ip.FloatingIP)
+
+	return ip.ID
+}
+
+func getFloatingIP(t *testing.T, ipID string) {
+	ip, err := floatingips.Get(base.Client, ipID).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting floating IP: ID [%s] Status [%s]", ip.ID, ip.Status)
+}
+
+func updateFloatingIP(t *testing.T, ipID, portID string) {
+	t.Logf("Disassociate all ports from IP %s", ipID)
+	_, err := floatingips.Update(base.Client, ipID, floatingips.UpdateOpts{PortID: ""}).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Re-associate the port %s", portID)
+	_, err = floatingips.Update(base.Client, ipID, floatingips.UpdateOpts{PortID: portID}).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func deleteFloatingIP(t *testing.T, ipID string) {
+	t.Logf("Deleting IP %s", ipID)
+	res := floatingips.Delete(base.Client, ipID)
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/common.go b/acceptance/openstack/networking/v2/extensions/lbaas/common.go
new file mode 100644
index 0000000..045a8ae
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/common.go
@@ -0,0 +1,78 @@
+package lbaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func SetupTopology(t *testing.T) (string, string) {
+	// create network
+	n, err := networks.Create(base.Client, networks.CreateOpts{Name: "tmp_network"}).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created network %s", n.ID)
+
+	// create subnet
+	s, err := subnets.Create(base.Client, subnets.CreateOpts{
+		NetworkID: n.ID,
+		CIDR:      "192.168.199.0/24",
+		IPVersion: subnets.IPv4,
+		Name:      "tmp_subnet",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created subnet %s", s.ID)
+
+	return n.ID, s.ID
+}
+
+func DeleteTopology(t *testing.T, networkID string) {
+	res := networks.Delete(base.Client, networkID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted network %s", networkID)
+}
+
+func CreatePool(t *testing.T, subnetID string) string {
+	p, err := pools.Create(base.Client, pools.CreateOpts{
+		LBMethod: pools.LBMethodRoundRobin,
+		Protocol: "HTTP",
+		Name:     "tmp_pool",
+		SubnetID: subnetID,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created pool %s", p.ID)
+
+	return p.ID
+}
+
+func DeletePool(t *testing.T, poolID string) {
+	res := pools.Delete(base.Client, poolID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted pool %s", poolID)
+}
+
+func CreateMonitor(t *testing.T) string {
+	m, err := monitors.Create(base.Client, monitors.CreateOpts{
+		Delay:         10,
+		Timeout:       10,
+		MaxRetries:    3,
+		Type:          monitors.TypeHTTP,
+		ExpectedCodes: "200",
+		URLPath:       "/login",
+		HTTPMethod:    "GET",
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created monitor ID [%s]", m.ID)
+
+	return m.ID
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go
new file mode 100644
index 0000000..dce3bbb
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/member_test.go
@@ -0,0 +1,95 @@
+// +build acceptance networking lbaas lbaasmember
+
+package lbaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/members"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestMembers(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// setup
+	networkID, subnetID := SetupTopology(t)
+	poolID := CreatePool(t, subnetID)
+
+	// create member
+	memberID := createMember(t, poolID)
+
+	// list members
+	listMembers(t)
+
+	// update member
+	updateMember(t, memberID)
+
+	// get member
+	getMember(t, memberID)
+
+	// delete member
+	deleteMember(t, memberID)
+
+	// teardown
+	DeletePool(t, poolID)
+	DeleteTopology(t, networkID)
+}
+
+func createMember(t *testing.T, poolID string) string {
+	m, err := members.Create(base.Client, members.CreateOpts{
+		Address:      "192.168.199.1",
+		ProtocolPort: 8080,
+		PoolID:       poolID,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created member: ID [%s] Status [%s] Weight [%d] Address [%s] Port [%d]",
+		m.ID, m.Status, m.Weight, m.Address, m.ProtocolPort)
+
+	return m.ID
+}
+
+func listMembers(t *testing.T) {
+	err := members.List(base.Client, members.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		memberList, err := members.ExtractMembers(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+
+		for _, m := range memberList {
+			t.Logf("Listing member: ID [%s] Status [%s]", m.ID, m.Status)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updateMember(t *testing.T, memberID string) {
+	m, err := members.Update(base.Client, memberID, members.UpdateOpts{AdminStateUp: true}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Updated member ID [%s]", m.ID)
+}
+
+func getMember(t *testing.T, memberID string) {
+	m, err := members.Get(base.Client, memberID).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting member ID [%s]", m.ID)
+}
+
+func deleteMember(t *testing.T, memberID string) {
+	res := members.Delete(base.Client, memberID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted member %s", memberID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go
new file mode 100644
index 0000000..e8e7192
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/monitor_test.go
@@ -0,0 +1,77 @@
+// +build acceptance networking lbaas lbaasmonitor
+
+package lbaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/monitors"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestMonitors(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// create monitor
+	monitorID := CreateMonitor(t)
+
+	// list monitors
+	listMonitors(t)
+
+	// update monitor
+	updateMonitor(t, monitorID)
+
+	// get monitor
+	getMonitor(t, monitorID)
+
+	// delete monitor
+	deleteMonitor(t, monitorID)
+}
+
+func listMonitors(t *testing.T) {
+	err := monitors.List(base.Client, monitors.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		monitorList, err := monitors.ExtractMonitors(page)
+		if err != nil {
+			t.Errorf("Failed to extract monitors: %v", err)
+			return false, err
+		}
+
+		for _, m := range monitorList {
+			t.Logf("Listing monitor: ID [%s] Type [%s] Delay [%ds] Timeout [%d] Retries [%d] Status [%s]",
+				m.ID, m.Type, m.Delay, m.Timeout, m.MaxRetries, m.Status)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updateMonitor(t *testing.T, monitorID string) {
+	opts := monitors.UpdateOpts{Delay: 10, Timeout: 10, MaxRetries: 3}
+	m, err := monitors.Update(base.Client, monitorID, opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Updated monitor ID [%s]", m.ID)
+}
+
+func getMonitor(t *testing.T, monitorID string) {
+	m, err := monitors.Get(base.Client, monitorID).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting monitor ID [%s]: URL path [%s] HTTP Method [%s] Accepted codes [%s]",
+		m.ID, m.URLPath, m.HTTPMethod, m.ExpectedCodes)
+}
+
+func deleteMonitor(t *testing.T, monitorID string) {
+	res := monitors.Delete(base.Client, monitorID)
+
+	th.AssertNoErr(t, res.Err)
+
+	t.Logf("Deleted monitor %s", monitorID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go b/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go
new file mode 100644
index 0000000..f5a7df7
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/pkg.go
@@ -0,0 +1 @@
+package lbaas
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go
new file mode 100644
index 0000000..70ee844
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/pool_test.go
@@ -0,0 +1,98 @@
+// +build acceptance networking lbaas lbaaspool
+
+package lbaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/pools"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestPools(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// setup
+	networkID, subnetID := SetupTopology(t)
+
+	// create pool
+	poolID := CreatePool(t, subnetID)
+
+	// list pools
+	listPools(t)
+
+	// update pool
+	updatePool(t, poolID)
+
+	// get pool
+	getPool(t, poolID)
+
+	// create monitor
+	monitorID := CreateMonitor(t)
+
+	// associate health monitor
+	associateMonitor(t, poolID, monitorID)
+
+	// disassociate health monitor
+	disassociateMonitor(t, poolID, monitorID)
+
+	// delete pool
+	DeletePool(t, poolID)
+
+	// teardown
+	DeleteTopology(t, networkID)
+}
+
+func listPools(t *testing.T) {
+	err := pools.List(base.Client, pools.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		poolList, err := pools.ExtractPools(page)
+		if err != nil {
+			t.Errorf("Failed to extract pools: %v", err)
+			return false, err
+		}
+
+		for _, p := range poolList {
+			t.Logf("Listing pool: ID [%s] Name [%s] Status [%s] LB algorithm [%s]", p.ID, p.Name, p.Status, p.LBMethod)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updatePool(t *testing.T, poolID string) {
+	opts := pools.UpdateOpts{Name: "SuperPool", LBMethod: pools.LBMethodLeastConnections}
+	p, err := pools.Update(base.Client, poolID, opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Updated pool ID [%s]", p.ID)
+}
+
+func getPool(t *testing.T, poolID string) {
+	p, err := pools.Get(base.Client, poolID).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting pool ID [%s]", p.ID)
+}
+
+func associateMonitor(t *testing.T, poolID, monitorID string) {
+	res := pools.AssociateMonitor(base.Client, poolID, monitorID)
+
+	th.AssertNoErr(t, res.Err)
+
+	t.Logf("Associated pool %s with monitor %s", poolID, monitorID)
+}
+
+func disassociateMonitor(t *testing.T, poolID, monitorID string) {
+	res := pools.DisassociateMonitor(base.Client, poolID, monitorID)
+
+	th.AssertNoErr(t, res.Err)
+
+	t.Logf("Disassociated pool %s with monitor %s", poolID, monitorID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go b/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go
new file mode 100644
index 0000000..d38e9c1
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/lbaas/vip_test.go
@@ -0,0 +1,101 @@
+// +build acceptance networking lbaas lbaasvip
+
+package lbaas
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/lbaas/vips"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestVIPs(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// setup
+	networkID, subnetID := SetupTopology(t)
+	poolID := CreatePool(t, subnetID)
+
+	// create VIP
+	VIPID := createVIP(t, subnetID, poolID)
+
+	// list VIPs
+	listVIPs(t)
+
+	// update VIP
+	updateVIP(t, VIPID)
+
+	// get VIP
+	getVIP(t, VIPID)
+
+	// delete VIP
+	deleteVIP(t, VIPID)
+
+	// teardown
+	DeletePool(t, poolID)
+	DeleteTopology(t, networkID)
+}
+
+func createVIP(t *testing.T, subnetID, poolID string) string {
+	p, err := vips.Create(base.Client, vips.CreateOpts{
+		Protocol:     "HTTP",
+		Name:         "New_VIP",
+		AdminStateUp: vips.Up,
+		SubnetID:     subnetID,
+		PoolID:       poolID,
+		ProtocolPort: 80,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created pool %s", p.ID)
+
+	return p.ID
+}
+
+func listVIPs(t *testing.T) {
+	err := vips.List(base.Client, vips.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		vipList, err := vips.ExtractVIPs(page)
+		if err != nil {
+			t.Errorf("Failed to extract VIPs: %v", err)
+			return false, err
+		}
+
+		for _, vip := range vipList {
+			t.Logf("Listing VIP: ID [%s] Name [%s] Address [%s] Port [%s] Connection Limit [%d]",
+				vip.ID, vip.Name, vip.Address, vip.ProtocolPort, vip.ConnLimit)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func updateVIP(t *testing.T, VIPID string) {
+	i1000 := 1000
+	_, err := vips.Update(base.Client, VIPID, vips.UpdateOpts{ConnLimit: &i1000}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Updated VIP ID [%s]", VIPID)
+}
+
+func getVIP(t *testing.T, VIPID string) {
+	vip, err := vips.Get(base.Client, VIPID).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Getting VIP ID [%s]: Status [%s]", vip.ID, vip.Status)
+}
+
+func deleteVIP(t *testing.T, VIPID string) {
+	res := vips.Delete(base.Client, VIPID)
+
+	th.AssertNoErr(t, res.Err)
+
+	t.Logf("Deleted VIP %s", VIPID)
+}
diff --git a/acceptance/openstack/networking/v2/extensions/pkg.go b/acceptance/openstack/networking/v2/extensions/pkg.go
new file mode 100644
index 0000000..aeec0fa
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/pkg.go
@@ -0,0 +1 @@
+package extensions
diff --git a/acceptance/openstack/networking/v2/extensions/provider_test.go b/acceptance/openstack/networking/v2/extensions/provider_test.go
new file mode 100644
index 0000000..55acbc9
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/provider_test.go
@@ -0,0 +1,68 @@
+// +build acceptance networking
+
+package extensions
+
+import (
+	"strconv"
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// Create a network
+	n, err := networks.Create(base.Client, networks.CreateOpts{Name: "sample_network", AdminStateUp: networks.Up}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	networkID := n.ID
+
+	// List networks
+	pager := networks.List(base.Client, networks.ListOpts{Limit: 2})
+	err = pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		networkList, err := networks.ExtractNetworks(page)
+		th.AssertNoErr(t, err)
+
+		for _, n := range networkList {
+			t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+				n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+
+	// Get a network
+	if networkID == "" {
+		t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+	}
+	n, err = networks.Get(base.Client, networkID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{})
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.Shared, false)
+	th.AssertEquals(t, n.ID, networkID)
+
+	// Update network
+	n, err = networks.Update(base.Client, networkID, networks.UpdateOpts{Name: "new_network_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Name, "new_network_name")
+
+	// Delete network
+	res := networks.Delete(base.Client, networkID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateMultipleNetworks(t *testing.T) {
+	//networks.CreateMany()
+}
diff --git a/acceptance/openstack/networking/v2/extensions/security_test.go b/acceptance/openstack/networking/v2/extensions/security_test.go
new file mode 100644
index 0000000..fe02ada
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/security_test.go
@@ -0,0 +1,171 @@
+// +build acceptance networking security
+
+package extensions
+
+import (
+	"testing"
+
+	base "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/groups"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestSecurityGroups(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// create security group
+	groupID := createSecGroup(t)
+
+	// delete security group
+	defer deleteSecGroup(t, groupID)
+
+	// list security group
+	listSecGroups(t)
+
+	// get security group
+	getSecGroup(t, groupID)
+
+	// create port with security group
+	networkID, portID := createPort(t, groupID)
+
+	// teardown
+	defer deleteNetwork(t, networkID)
+
+	// delete port
+	defer deletePort(t, portID)
+}
+
+func TestSecurityGroupRules(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// create security group
+	groupID := createSecGroup(t)
+
+	defer deleteSecGroup(t, groupID)
+
+	// create security group rule
+	ruleID := createSecRule(t, groupID)
+
+	// delete security group rule
+	defer deleteSecRule(t, ruleID)
+
+	// list security group rule
+	listSecRules(t)
+
+	// get security group rule
+	getSecRule(t, ruleID)
+}
+
+func createSecGroup(t *testing.T) string {
+	sg, err := groups.Create(base.Client, groups.CreateOpts{
+		Name:        "new-webservers",
+		Description: "security group for webservers",
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created security group %s", sg.ID)
+
+	return sg.ID
+}
+
+func listSecGroups(t *testing.T) {
+	err := groups.List(base.Client, groups.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		list, err := groups.ExtractGroups(page)
+		if err != nil {
+			t.Errorf("Failed to extract secgroups: %v", err)
+			return false, err
+		}
+
+		for _, sg := range list {
+			t.Logf("Listing security group: ID [%s] Name [%s]", sg.ID, sg.Name)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func getSecGroup(t *testing.T, id string) {
+	sg, err := groups.Get(base.Client, id).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting security group: ID [%s] Name [%s] Description [%s]", sg.ID, sg.Name, sg.Description)
+}
+
+func createPort(t *testing.T, groupID string) (string, string) {
+	n, err := networks.Create(base.Client, networks.CreateOpts{Name: "tmp_network"}).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created network %s", n.ID)
+
+	opts := ports.CreateOpts{
+		NetworkID:      n.ID,
+		Name:           "my_port",
+		SecurityGroups: []string{groupID},
+	}
+	p, err := ports.Create(base.Client, opts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created port %s with security group %s", p.ID, groupID)
+
+	return n.ID, p.ID
+}
+
+func deleteSecGroup(t *testing.T, groupID string) {
+	res := groups.Delete(base.Client, groupID)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted security group %s", groupID)
+}
+
+func createSecRule(t *testing.T, groupID string) string {
+	r, err := rules.Create(base.Client, rules.CreateOpts{
+		Direction:    "ingress",
+		PortRangeMin: 80,
+		EtherType:    "IPv4",
+		PortRangeMax: 80,
+		Protocol:     "tcp",
+		SecGroupID:   groupID,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+
+	t.Logf("Created security group rule %s", r.ID)
+
+	return r.ID
+}
+
+func listSecRules(t *testing.T) {
+	err := rules.List(base.Client, rules.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		list, err := rules.ExtractRules(page)
+		if err != nil {
+			t.Errorf("Failed to extract sec rules: %v", err)
+			return false, err
+		}
+
+		for _, r := range list {
+			t.Logf("Listing security rule: ID [%s]", r.ID)
+		}
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
+
+func getSecRule(t *testing.T, id string) {
+	r, err := rules.Get(base.Client, id).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Getting security rule: ID [%s] Direction [%s] EtherType [%s] Protocol [%s]",
+		r.ID, r.Direction, r.EtherType, r.Protocol)
+}
+
+func deleteSecRule(t *testing.T, id string) {
+	res := rules.Delete(base.Client, id)
+	th.AssertNoErr(t, res.Err)
+	t.Logf("Deleted security rule %s", id)
+}
diff --git a/acceptance/openstack/networking/v2/network_test.go b/acceptance/openstack/networking/v2/network_test.go
new file mode 100644
index 0000000..1926999
--- /dev/null
+++ b/acceptance/openstack/networking/v2/network_test.go
@@ -0,0 +1,68 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"strconv"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestNetworkCRUDOperations(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Create a network
+	n, err := networks.Create(Client, networks.CreateOpts{Name: "sample_network", AdminStateUp: networks.Up}).Extract()
+	th.AssertNoErr(t, err)
+	defer networks.Delete(Client, n.ID)
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	networkID := n.ID
+
+	// List networks
+	pager := networks.List(Client, networks.ListOpts{Limit: 2})
+	err = pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		networkList, err := networks.ExtractNetworks(page)
+		th.AssertNoErr(t, err)
+
+		for _, n := range networkList {
+			t.Logf("Network: ID [%s] Name [%s] Status [%s] Is shared? [%s]",
+				n.ID, n.Name, n.Status, strconv.FormatBool(n.Shared))
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+
+	// Get a network
+	if networkID == "" {
+		t.Fatalf("In order to retrieve a network, the NetworkID must be set")
+	}
+	n, err = networks.Get(Client, networkID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{})
+	th.AssertEquals(t, n.Name, "sample_network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.Shared, false)
+	th.AssertEquals(t, n.ID, networkID)
+
+	// Update network
+	n, err = networks.Update(Client, networkID, networks.UpdateOpts{Name: "new_network_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, n.Name, "new_network_name")
+
+	// Delete network
+	res := networks.Delete(Client, networkID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateMultipleNetworks(t *testing.T) {
+	//networks.CreateMany()
+}
diff --git a/acceptance/openstack/networking/v2/pkg.go b/acceptance/openstack/networking/v2/pkg.go
new file mode 100644
index 0000000..5ec3cc8
--- /dev/null
+++ b/acceptance/openstack/networking/v2/pkg.go
@@ -0,0 +1 @@
+package v2
diff --git a/acceptance/openstack/networking/v2/port_test.go b/acceptance/openstack/networking/v2/port_test.go
new file mode 100644
index 0000000..2ef3408
--- /dev/null
+++ b/acceptance/openstack/networking/v2/port_test.go
@@ -0,0 +1,130 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestPortCRUD(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Setup network
+	t.Log("Setting up network")
+	networkID, err := createNetwork()
+	th.AssertNoErr(t, err)
+	defer networks.Delete(Client, networkID)
+
+	// Setup subnet
+	t.Logf("Setting up subnet on network %s", networkID)
+	subnetID, err := createSubnet(networkID)
+	th.AssertNoErr(t, err)
+	defer subnets.Delete(Client, subnetID)
+
+	// Create port
+	t.Logf("Create port based on subnet %s", subnetID)
+	portID := createPort(t, networkID, subnetID)
+
+	// List ports
+	t.Logf("Listing all ports")
+	listPorts(t)
+
+	// Get port
+	if portID == "" {
+		t.Fatalf("In order to retrieve a port, the portID must be set")
+	}
+	p, err := ports.Get(Client, portID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.ID, portID)
+
+	// Update port
+	updateOpts := ports.UpdateOpts{
+		Name: "new_port_name",
+		AllowedAddressPairs: []ports.AddressPair{
+			ports.AddressPair{IPAddress: "192.168.199.201"},
+		},
+	}
+	p, err = ports.Update(Client, portID, updateOpts).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.Name, "new_port_name")
+
+	updatedPort, err := ports.Get(Client, portID).Extract()
+	th.AssertEquals(t, updatedPort.AllowedAddressPairs[0].IPAddress, "192.168.199.201")
+
+	// Delete port
+	res := ports.Delete(Client, portID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func createPort(t *testing.T, networkID, subnetID string) string {
+	enable := false
+	opts := ports.CreateOpts{
+		NetworkID:    networkID,
+		Name:         "my_port",
+		AdminStateUp: &enable,
+		FixedIPs:     []ports.IP{ports.IP{SubnetID: subnetID}},
+	}
+	p, err := ports.Create(Client, opts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.NetworkID, networkID)
+	th.AssertEquals(t, p.Name, "my_port")
+	th.AssertEquals(t, p.AdminStateUp, false)
+
+	return p.ID
+}
+
+func listPorts(t *testing.T) {
+	count := 0
+	pager := ports.List(Client, ports.ListOpts{})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		t.Logf("--- Page ---")
+
+		portList, err := ports.ExtractPorts(page)
+		th.AssertNoErr(t, err)
+
+		for _, p := range portList {
+			t.Logf("Port: ID [%s] Name [%s] Status [%s] MAC addr [%s] Fixed IPs [%#v] Security groups [%#v] Allowed Address Pairs [%#v]",
+				p.ID, p.Name, p.Status, p.MACAddress, p.FixedIPs, p.SecurityGroups, p.AllowedAddressPairs)
+		}
+
+		return true, nil
+	})
+
+	th.CheckNoErr(t, err)
+
+	if count == 0 {
+		t.Logf("No pages were iterated over when listing ports")
+	}
+}
+
+func createNetwork() (string, error) {
+	res, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: networks.Up}).Extract()
+	return res.ID, err
+}
+
+func createSubnet(networkID string) (string, error) {
+	s, err := subnets.Create(Client, subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       "192.168.199.0/24",
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: subnets.Down,
+		AllocationPools: []subnets.AllocationPool{
+			subnets.AllocationPool{Start: "192.168.199.2", End: "192.168.199.200"},
+		},
+	}).Extract()
+	return s.ID, err
+}
+
+func TestPortBatchCreate(t *testing.T) {
+	// todo
+}
diff --git a/acceptance/openstack/networking/v2/subnet_test.go b/acceptance/openstack/networking/v2/subnet_test.go
new file mode 100644
index 0000000..f6ce17e
--- /dev/null
+++ b/acceptance/openstack/networking/v2/subnet_test.go
@@ -0,0 +1,86 @@
+// +build acceptance networking
+
+package v2
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/subnets"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	pager := subnets.List(Client, subnets.ListOpts{Limit: 2})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		t.Logf("--- Page ---")
+
+		subnetList, err := subnets.ExtractSubnets(page)
+		th.AssertNoErr(t, err)
+
+		for _, s := range subnetList {
+			t.Logf("Subnet: ID [%s] Name [%s] IP Version [%d] CIDR [%s] GatewayIP [%s]",
+				s.ID, s.Name, s.IPVersion, s.CIDR, s.GatewayIP)
+		}
+
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestCRUD(t *testing.T) {
+	Setup(t)
+	defer Teardown()
+
+	// Setup network
+	t.Log("Setting up network")
+	n, err := networks.Create(Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: networks.Up}).Extract()
+	th.AssertNoErr(t, err)
+	networkID := n.ID
+	defer networks.Delete(Client, networkID)
+
+	// Create subnet
+	t.Log("Create subnet")
+	enable := false
+	opts := subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       "192.168.199.0/24",
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: &enable,
+	}
+	s, err := subnets.Create(Client, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.NetworkID, networkID)
+	th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.Name, "my_subnet")
+	th.AssertEquals(t, s.EnableDHCP, false)
+	subnetID := s.ID
+
+	// Get subnet
+	t.Log("Getting subnet")
+	s, err = subnets.Get(Client, subnetID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, s.ID, subnetID)
+
+	// Update subnet
+	t.Log("Update subnet")
+	s, err = subnets.Update(Client, subnetID, subnets.UpdateOpts{Name: "new_subnet_name"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, s.Name, "new_subnet_name")
+
+	// Delete subnet
+	t.Log("Delete subnet")
+	res := subnets.Delete(Client, subnetID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestBatchCreate(t *testing.T) {
+	// todo
+}
diff --git a/acceptance/openstack/objectstorage/v1/accounts_test.go b/acceptance/openstack/objectstorage/v1/accounts_test.go
new file mode 100644
index 0000000..5a29235
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/accounts_test.go
@@ -0,0 +1,50 @@
+// +build acceptance
+
+package v1
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/objectstorage/v1/accounts"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAccounts(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	// Update an account's metadata.
+	updateres := accounts.Update(client, accounts.UpdateOpts{Metadata: metadata})
+	t.Logf("Update Account Response: %+v\n", updateres)
+	updateHeaders, err := updateres.Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Update Account Response Headers: %+v\n", updateHeaders)
+
+	// Defer the deletion of the metadata set above.
+	defer func() {
+		tempMap := make(map[string]string)
+		for k := range metadata {
+			tempMap[k] = ""
+		}
+		updateres = accounts.Update(client, accounts.UpdateOpts{Metadata: tempMap})
+		th.AssertNoErr(t, updateres.Err)
+	}()
+
+	// Extract the custom metadata from the 'Get' response.
+	res := accounts.Get(client, nil)
+
+	h, err := res.Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Get Account Response Headers: %+v\n", h)
+
+	am, err := res.ExtractMetadata()
+	th.AssertNoErr(t, err)
+	for k := range metadata {
+		if am[k] != metadata[strings.Title(k)] {
+			t.Errorf("Expected custom metadata with key: %s", k)
+			return
+		}
+	}
+}
diff --git a/acceptance/openstack/objectstorage/v1/common.go b/acceptance/openstack/objectstorage/v1/common.go
new file mode 100644
index 0000000..1114ed5
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/common.go
@@ -0,0 +1,28 @@
+// +build acceptance
+
+package v1
+
+import (
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+var metadata = map[string]string{"gopher": "cloud"}
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := openstack.AuthenticatedClient(ao)
+	th.AssertNoErr(t, err)
+
+	c, err := openstack.NewObjectStorageV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+	th.AssertNoErr(t, err)
+	return c
+}
diff --git a/acceptance/openstack/objectstorage/v1/containers_test.go b/acceptance/openstack/objectstorage/v1/containers_test.go
new file mode 100644
index 0000000..056b2a9
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/containers_test.go
@@ -0,0 +1,137 @@
+// +build acceptance
+
+package v1
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// numContainers is the number of containers to create for testing.
+var numContainers = 2
+
+func TestContainers(t *testing.T) {
+	// Create a new client to execute the HTTP requests. See common.go for newClient body.
+	client := newClient(t)
+
+	// Create a slice of random container names.
+	cNames := make([]string, numContainers)
+	for i := 0; i < numContainers; i++ {
+		cNames[i] = tools.RandomString("gophercloud-test-container-", 8)
+	}
+
+	// Create numContainers containers.
+	for i := 0; i < len(cNames); i++ {
+		res := containers.Create(client, cNames[i], nil)
+		th.AssertNoErr(t, res.Err)
+	}
+	// Delete the numContainers containers after function completion.
+	defer func() {
+		for i := 0; i < len(cNames); i++ {
+			res := containers.Delete(client, cNames[i])
+			th.AssertNoErr(t, res.Err)
+		}
+	}()
+
+	// List the numContainer names that were just created. To just list those,
+	// the 'prefix' parameter is used.
+	err := containers.List(client, &containers.ListOpts{Full: true, Prefix: "gophercloud-test-container-"}).EachPage(func(page pagination.Page) (bool, error) {
+		containerList, err := containers.ExtractInfo(page)
+		th.AssertNoErr(t, err)
+
+		for _, n := range containerList {
+			t.Logf("Container: Name [%s] Count [%d] Bytes [%d]",
+				n.Name, n.Count, n.Bytes)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	// List the info for the numContainer containers that were created.
+	err = containers.List(client, &containers.ListOpts{Full: false, Prefix: "gophercloud-test-container-"}).EachPage(func(page pagination.Page) (bool, error) {
+		containerList, err := containers.ExtractNames(page)
+		th.AssertNoErr(t, err)
+		for _, n := range containerList {
+			t.Logf("Container: Name [%s]", n)
+		}
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	// Update one of the numContainer container metadata.
+	updateres := containers.Update(client, cNames[0], &containers.UpdateOpts{Metadata: metadata})
+	th.AssertNoErr(t, updateres.Err)
+	// After the tests are done, delete the metadata that was set.
+	defer func() {
+		tempMap := make(map[string]string)
+		for k := range metadata {
+			tempMap[k] = ""
+		}
+		res := containers.Update(client, cNames[0], &containers.UpdateOpts{Metadata: tempMap})
+		th.AssertNoErr(t, res.Err)
+	}()
+
+	// Retrieve a container's metadata.
+	cm, err := containers.Get(client, cNames[0]).ExtractMetadata()
+	th.AssertNoErr(t, err)
+	for k := range metadata {
+		if cm[k] != metadata[strings.Title(k)] {
+			t.Errorf("Expected custom metadata with key: %s", k)
+		}
+	}
+}
+
+func TestListAllContainers(t *testing.T) {
+	// Create a new client to execute the HTTP requests. See common.go for newClient body.
+	client := newClient(t)
+
+	numContainers := 20
+
+	// Create a slice of random container names.
+	cNames := make([]string, numContainers)
+	for i := 0; i < numContainers; i++ {
+		cNames[i] = tools.RandomString("gophercloud-test-container-", 8)
+	}
+
+	// Create numContainers containers.
+	for i := 0; i < len(cNames); i++ {
+		res := containers.Create(client, cNames[i], nil)
+		th.AssertNoErr(t, res.Err)
+	}
+	// Delete the numContainers containers after function completion.
+	defer func() {
+		for i := 0; i < len(cNames); i++ {
+			res := containers.Delete(client, cNames[i])
+			th.AssertNoErr(t, res.Err)
+		}
+	}()
+
+	// List all the numContainer names that were just created. To just list those,
+	// the 'prefix' parameter is used.
+	allPages, err := containers.List(client, &containers.ListOpts{Full: true, Limit: 5, Prefix: "gophercloud-test-container-"}).AllPages()
+	th.AssertNoErr(t, err)
+	containerInfoList, err := containers.ExtractInfo(allPages)
+	th.AssertNoErr(t, err)
+	for _, n := range containerInfoList {
+		t.Logf("Container: Name [%s] Count [%d] Bytes [%d]",
+			n.Name, n.Count, n.Bytes)
+	}
+	th.AssertEquals(t, numContainers, len(containerInfoList))
+
+	// List the info for all the numContainer containers that were created.
+	allPages, err = containers.List(client, &containers.ListOpts{Full: false, Limit: 2, Prefix: "gophercloud-test-container-"}).AllPages()
+	th.AssertNoErr(t, err)
+	containerNamesList, err := containers.ExtractNames(allPages)
+	th.AssertNoErr(t, err)
+	for _, n := range containerNamesList {
+		t.Logf("Container: Name [%s]", n)
+	}
+	th.AssertEquals(t, numContainers, len(containerNamesList))
+}
diff --git a/acceptance/openstack/objectstorage/v1/objects_test.go b/acceptance/openstack/objectstorage/v1/objects_test.go
new file mode 100644
index 0000000..3a27738
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/objects_test.go
@@ -0,0 +1,119 @@
+// +build acceptance
+
+package v1
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/acceptance/tools"
+	"github.com/gophercloud/gophercloud/openstack/objectstorage/v1/containers"
+	"github.com/gophercloud/gophercloud/openstack/objectstorage/v1/objects"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// numObjects is the number of objects to create for testing.
+var numObjects = 2
+
+func TestObjects(t *testing.T) {
+	// Create a provider client for executing the HTTP request.
+	// See common.go for more information.
+	client := newClient(t)
+
+	// Make a slice of length numObjects to hold the random object names.
+	oNames := make([]string, numObjects)
+	for i := 0; i < len(oNames); i++ {
+		oNames[i] = tools.RandomString("test-object-", 8)
+	}
+
+	// Create a container to hold the test objects.
+	cName := tools.RandomString("test-container-", 8)
+	header, err := containers.Create(client, cName, nil).ExtractHeader()
+	th.AssertNoErr(t, err)
+	t.Logf("Create object headers: %+v\n", header)
+
+	// Defer deletion of the container until after testing.
+	defer func() {
+		res := containers.Delete(client, cName)
+		th.AssertNoErr(t, res.Err)
+	}()
+
+	// Create a slice of buffers to hold the test object content.
+	oContents := make([]*bytes.Buffer, numObjects)
+	for i := 0; i < numObjects; i++ {
+		oContents[i] = bytes.NewBuffer([]byte(tools.RandomString("", 10)))
+		res := objects.Create(client, cName, oNames[i], oContents[i], nil)
+		th.AssertNoErr(t, res.Err)
+	}
+	// Delete the objects after testing.
+	defer func() {
+		for i := 0; i < numObjects; i++ {
+			res := objects.Delete(client, cName, oNames[i], nil)
+			th.AssertNoErr(t, res.Err)
+		}
+	}()
+
+	ons := make([]string, 0, len(oNames))
+	err = objects.List(client, cName, &objects.ListOpts{Full: false, Prefix: "test-object-"}).EachPage(func(page pagination.Page) (bool, error) {
+		names, err := objects.ExtractNames(page)
+		th.AssertNoErr(t, err)
+		ons = append(ons, names...)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, len(ons), len(oNames))
+
+	ois := make([]objects.Object, 0, len(oNames))
+	err = objects.List(client, cName, &objects.ListOpts{Full: true, Prefix: "test-object-"}).EachPage(func(page pagination.Page) (bool, error) {
+		info, err := objects.ExtractInfo(page)
+		th.AssertNoErr(t, err)
+
+		ois = append(ois, info...)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, len(ois), len(oNames))
+
+	// Copy the contents of one object to another.
+	copyres := objects.Copy(client, cName, oNames[0], &objects.CopyOpts{Destination: cName + "/" + oNames[1]})
+	th.AssertNoErr(t, copyres.Err)
+
+	// Download one of the objects that was created above.
+	o1Content, err := objects.Download(client, cName, oNames[0], nil).ExtractContent()
+	th.AssertNoErr(t, err)
+
+	// Download the another object that was create above.
+	o2Content, err := objects.Download(client, cName, oNames[1], nil).ExtractContent()
+	th.AssertNoErr(t, err)
+
+	// Compare the two object's contents to test that the copy worked.
+	th.AssertEquals(t, string(o2Content), string(o1Content))
+
+	// Update an object's metadata.
+	updateres := objects.Update(client, cName, oNames[0], &objects.UpdateOpts{Metadata: metadata})
+	th.AssertNoErr(t, updateres.Err)
+
+	// Delete the object's metadata after testing.
+	defer func() {
+		tempMap := make(map[string]string)
+		for k := range metadata {
+			tempMap[k] = ""
+		}
+		res := objects.Update(client, cName, oNames[0], &objects.UpdateOpts{Metadata: tempMap})
+		th.AssertNoErr(t, res.Err)
+	}()
+
+	// Retrieve an object's metadata.
+	om, err := objects.Get(client, cName, oNames[0], nil).ExtractMetadata()
+	th.AssertNoErr(t, err)
+	for k := range metadata {
+		if om[k] != metadata[strings.Title(k)] {
+			t.Errorf("Expected custom metadata with key: %s", k)
+			return
+		}
+	}
+}
diff --git a/acceptance/openstack/objectstorage/v1/pkg.go b/acceptance/openstack/objectstorage/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/objectstorage/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/orchestration/v1/buildinfo_test.go b/acceptance/openstack/orchestration/v1/buildinfo_test.go
new file mode 100644
index 0000000..1b48662
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/buildinfo_test.go
@@ -0,0 +1,20 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/buildinfo"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestBuildInfo(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	bi, err := buildinfo.Get(client).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("retrieved build info: %+v\n", bi)
+}
diff --git a/acceptance/openstack/orchestration/v1/common.go b/acceptance/openstack/orchestration/v1/common.go
new file mode 100644
index 0000000..4eec2e3
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/common.go
@@ -0,0 +1,44 @@
+// +build acceptance
+
+package v1
+
+import (
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+var template = fmt.Sprintf(`
+{
+	"heat_template_version": "2013-05-23",
+	"description": "Simple template to test heat commands",
+	"parameters": {},
+	"resources": {
+		"hello_world": {
+			"type":"OS::Nova::Server",
+			"properties": {
+				"flavor": "%s",
+				"image": "%s",
+				"user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n"
+			}
+		}
+	}
+}`, os.Getenv("OS_FLAVOR_ID"), os.Getenv("OS_IMAGE_ID"))
+
+func newClient(t *testing.T) *gophercloud.ServiceClient {
+	ao, err := openstack.AuthOptionsFromEnv()
+	th.AssertNoErr(t, err)
+
+	client, err := openstack.AuthenticatedClient(ao)
+	th.AssertNoErr(t, err)
+
+	c, err := openstack.NewOrchestrationV1(client, gophercloud.EndpointOpts{
+		Region: os.Getenv("OS_REGION_NAME"),
+	})
+	th.AssertNoErr(t, err)
+	return c
+}
diff --git a/acceptance/openstack/orchestration/v1/hello-compute.json b/acceptance/openstack/orchestration/v1/hello-compute.json
new file mode 100644
index 0000000..11cfc80
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/hello-compute.json
@@ -0,0 +1,13 @@
+{
+  "heat_template_version": "2013-05-23",
+  "resources": {
+    "compute_instance": {
+      "type": "OS::Nova::Server",
+      "properties": {
+        "flavor": "m1.small",
+        "image": "cirros-0.3.2-x86_64-disk",
+        "name": "Single Compute Instance"
+      }
+    }
+  }
+}
diff --git a/acceptance/openstack/orchestration/v1/pkg.go b/acceptance/openstack/orchestration/v1/pkg.go
new file mode 100644
index 0000000..b7b1f99
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/pkg.go
@@ -0,0 +1 @@
+package v1
diff --git a/acceptance/openstack/orchestration/v1/stackevents_test.go b/acceptance/openstack/orchestration/v1/stackevents_test.go
new file mode 100644
index 0000000..4be4bf6
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackevents_test.go
@@ -0,0 +1,68 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackevents"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestStackEvents(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	stackName := "postman_stack_2"
+	resourceName := "hello_world"
+	var eventID string
+
+	createOpts := stacks.CreateOpts{
+		Name:     stackName,
+		Template: template,
+		Timeout:  5,
+	}
+	stack, err := stacks.Create(client, createOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created stack: %+v\n", stack)
+	defer func() {
+		err := stacks.Delete(client, stackName, stack.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Deleted stack (%s)", stackName)
+	}()
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "CREATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	err = stackevents.List(client, stackName, stack.ID, nil).EachPage(func(page pagination.Page) (bool, error) {
+		events, err := stackevents.ExtractEvents(page)
+		th.AssertNoErr(t, err)
+		t.Logf("listed events: %+v\n", events)
+		eventID = events[0].ID
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+
+	err = stackevents.ListResourceEvents(client, stackName, stack.ID, resourceName, nil).EachPage(func(page pagination.Page) (bool, error) {
+		resourceEvents, err := stackevents.ExtractEvents(page)
+		th.AssertNoErr(t, err)
+		t.Logf("listed resource events: %+v\n", resourceEvents)
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+
+	event, err := stackevents.Get(client, stackName, stack.ID, resourceName, eventID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("retrieved event: %+v\n", event)
+}
diff --git a/acceptance/openstack/orchestration/v1/stackresources_test.go b/acceptance/openstack/orchestration/v1/stackresources_test.go
new file mode 100644
index 0000000..50a0f06
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stackresources_test.go
@@ -0,0 +1,62 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stackresources"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestStackResources(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	stackName := "postman_stack_2"
+
+	createOpts := stacks.CreateOpts{
+		Name:     stackName,
+		Template: template,
+		Timeout:  5,
+	}
+	stack, err := stacks.Create(client, createOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created stack: %+v\n", stack)
+	defer func() {
+		err := stacks.Delete(client, stackName, stack.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Deleted stack (%s)", stackName)
+	}()
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "CREATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	resourceName := "hello_world"
+	resource, err := stackresources.Get(client, stackName, stack.ID, resourceName).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got stack resource: %+v\n", resource)
+
+	metadata, err := stackresources.Metadata(client, stackName, stack.ID, resourceName).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got stack resource metadata: %+v\n", metadata)
+
+	err = stackresources.List(client, stackName, stack.ID, stackresources.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		resources, err := stackresources.ExtractResources(page)
+		th.AssertNoErr(t, err)
+		t.Logf("resources: %+v\n", resources)
+		return false, nil
+	})
+	th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/orchestration/v1/stacks_test.go b/acceptance/openstack/orchestration/v1/stacks_test.go
new file mode 100644
index 0000000..c87cc5d
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacks_test.go
@@ -0,0 +1,153 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestStacks(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	stackName1 := "gophercloud-test-stack-2"
+	createOpts := stacks.CreateOpts{
+		Name:     stackName1,
+		Template: template,
+		Timeout:  5,
+	}
+	stack, err := stacks.Create(client, createOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created stack: %+v\n", stack)
+	defer func() {
+		err := stacks.Delete(client, stackName1, stack.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Deleted stack (%s)", stackName1)
+	}()
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "CREATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	updateOpts := stacks.UpdateOpts{
+		Template: template,
+		Timeout:  20,
+	}
+	err = stacks.Update(client, stackName1, stack.ID, updateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "UPDATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	t.Logf("Updated stack")
+
+	err = stacks.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+		stackList, err := stacks.ExtractStacks(page)
+		th.AssertNoErr(t, err)
+
+		t.Logf("Got stack list: %+v\n", stackList)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got stack: %+v\n", getStack)
+
+	abandonedStack, err := stacks.Abandon(client, stackName1, stack.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Abandonded stack %+v\n", abandonedStack)
+	th.AssertNoErr(t, err)
+}
+
+// Test using the updated interface
+func TestStacksNewTemplateFormat(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	stackName1 := "gophercloud-test-stack-2"
+	templateOpts := new(osStacks.Template)
+	templateOpts.Bin = []byte(template)
+	createOpts := osStacks.CreateOpts{
+		Name:         stackName1,
+		TemplateOpts: templateOpts,
+		Timeout:      5,
+	}
+	stack, err := stacks.Create(client, createOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created stack: %+v\n", stack)
+	defer func() {
+		err := stacks.Delete(client, stackName1, stack.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Deleted stack (%s)", stackName1)
+	}()
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "CREATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	updateOpts := osStacks.UpdateOpts{
+		TemplateOpts: templateOpts,
+		Timeout:      20,
+	}
+	err = stacks.Update(client, stackName1, stack.ID, updateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "UPDATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	t.Logf("Updated stack")
+
+	err = stacks.List(client, nil).EachPage(func(page pagination.Page) (bool, error) {
+		stackList, err := osStacks.ExtractStacks(page)
+		th.AssertNoErr(t, err)
+
+		t.Logf("Got stack list: %+v\n", stackList)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	getStack, err := stacks.Get(client, stackName1, stack.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Got stack: %+v\n", getStack)
+
+	abandonedStack, err := stacks.Abandon(client, stackName1, stack.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Abandonded stack %+v\n", abandonedStack)
+	th.AssertNoErr(t, err)
+}
diff --git a/acceptance/openstack/orchestration/v1/stacktemplates_test.go b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
new file mode 100644
index 0000000..9992e0c
--- /dev/null
+++ b/acceptance/openstack/orchestration/v1/stacktemplates_test.go
@@ -0,0 +1,75 @@
+// +build acceptance
+
+package v1
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacktemplates"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestStackTemplates(t *testing.T) {
+	// Create a provider client for making the HTTP requests.
+	// See common.go in this directory for more information.
+	client := newClient(t)
+
+	stackName := "postman_stack_2"
+
+	createOpts := stacks.CreateOpts{
+		Name:     stackName,
+		Template: template,
+		Timeout:  5,
+	}
+	stack, err := stacks.Create(client, createOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("Created stack: %+v\n", stack)
+	defer func() {
+		err := stacks.Delete(client, stackName, stack.ID).ExtractErr()
+		th.AssertNoErr(t, err)
+		t.Logf("Deleted stack (%s)", stackName)
+	}()
+	err = gophercloud.WaitFor(60, func() (bool, error) {
+		getStack, err := stacks.Get(client, stackName, stack.ID).Extract()
+		if err != nil {
+			return false, err
+		}
+		if getStack.Status == "CREATE_COMPLETE" {
+			return true, nil
+		}
+		return false, nil
+	})
+
+	tmpl, err := stacktemplates.Get(client, stackName, stack.ID).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("retrieved template: %+v\n", tmpl)
+
+	validateOpts := osStacktemplates.ValidateOpts{
+		Template: `{"heat_template_version": "2013-05-23",
+			"description": "Simple template to test heat commands",
+			"parameters": {
+				"flavor": {
+					"default": "m1.tiny",
+					"type":    "string",
+				},
+			},
+			"resources": {
+				"hello_world": {
+					"type": "OS::Nova::Server",
+					"properties": {
+						"key_name": "heat_key",
+						"flavor": {
+							"get_param": "flavor",
+						},
+						"image":     "ad091b52-742f-469e-8f3c-fd81cadf0743",
+						"user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n",
+					},
+				},
+			},
+		}`}
+	validatedTemplate, err := stacktemplates.Validate(client, validateOpts).Extract()
+	th.AssertNoErr(t, err)
+	t.Logf("validated template: %+v\n", validatedTemplate)
+}
diff --git a/acceptance/openstack/pkg.go b/acceptance/openstack/pkg.go
new file mode 100644
index 0000000..3a8ecdb
--- /dev/null
+++ b/acceptance/openstack/pkg.go
@@ -0,0 +1,4 @@
+// +build acceptance
+
+package openstack
+
diff --git a/acceptance/tools/pkg.go b/acceptance/tools/pkg.go
new file mode 100644
index 0000000..f7eca12
--- /dev/null
+++ b/acceptance/tools/pkg.go
@@ -0,0 +1 @@
+package tools
diff --git a/acceptance/tools/tools.go b/acceptance/tools/tools.go
new file mode 100644
index 0000000..bca4574
--- /dev/null
+++ b/acceptance/tools/tools.go
@@ -0,0 +1,67 @@
+// +build acceptance common
+
+package tools
+
+import (
+	"crypto/rand"
+	"errors"
+	mrand "math/rand"
+	"time"
+)
+
+// ErrTimeout is returned if WaitFor takes longer than 300 second to happen.
+var ErrTimeout = errors.New("Timed out")
+
+// WaitFor polls a predicate function once per second to wait for a certain state to arrive.
+func WaitFor(predicate func() (bool, error)) error {
+	for i := 0; i < 300; i++ {
+		time.Sleep(1 * time.Second)
+
+		satisfied, err := predicate()
+		if err != nil {
+			return err
+		}
+		if satisfied {
+			return nil
+		}
+	}
+	return ErrTimeout
+}
+
+// MakeNewPassword generates a new string that's guaranteed to be different than the given one.
+func MakeNewPassword(oldPass string) string {
+	randomPassword := RandomString("", 16)
+	for randomPassword == oldPass {
+		randomPassword = RandomString("", 16)
+	}
+	return randomPassword
+}
+
+// RandomString generates a string of given length, but random content.
+// All content will be within the ASCII graphic character set.
+// (Implementation from Even Shaw's contribution on
+// http://stackoverflow.com/questions/12771930/what-is-the-fastest-way-to-generate-a-long-random-string-in-go).
+func RandomString(prefix string, n int) string {
+	const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+	var bytes = make([]byte, n)
+	rand.Read(bytes)
+	for i, b := range bytes {
+		bytes[i] = alphanum[b%byte(len(alphanum))]
+	}
+	return prefix + string(bytes)
+}
+
+// RandomInt will return a random integer between a specified range.
+func RandomInt(min, max int) int {
+	mrand.Seed(time.Now().Unix())
+	return mrand.Intn(max-min) + min
+}
+
+// Elide returns the first bit of its input string with a suffix of "..." if it's longer than
+// a comfortable 40 characters.
+func Elide(value string) string {
+	if len(value) > 40 {
+		return value[0:37] + "..."
+	}
+	return value
+}
diff --git a/auth_options.go b/auth_options.go
new file mode 100644
index 0000000..922a279
--- /dev/null
+++ b/auth_options.go
@@ -0,0 +1,83 @@
+package gophercloud
+
+/*
+AuthOptions stores information needed to authenticate to an OpenStack cluster.
+You can populate one manually, or use a provider's AuthOptionsFromEnv() function
+to read relevant information from the standard environment variables. Pass one
+to a provider's AuthenticatedClient function to authenticate and obtain a
+ProviderClient representing an active session on that provider.
+
+Its fields are the union of those recognized by each identity implementation and
+provider.
+*/
+type AuthOptions struct {
+	// IdentityEndpoint specifies the HTTP endpoint that is required to work with
+	// the Identity API of the appropriate version. While it's ultimately needed by
+	// all of the identity services, it will often be populated by a provider-level
+	// function.
+	IdentityEndpoint string `json:"-"`
+
+	// Username is required if using Identity V2 API. Consult with your provider's
+	// control panel to discover your account's username. In Identity V3, either
+	// UserID or a combination of Username and DomainID or DomainName are needed.
+	Username string `json:"username,omitempty"`
+	UserID   string `json:"id,omitempty"`
+
+	Password string `json:"password,omitempty"`
+
+	// At most one of DomainID and DomainName must be provided if using Username
+	// with Identity V3. Otherwise, either are optional.
+	DomainID   string `json:"id,omitempty"`
+	DomainName string `json:"name,omitempty"`
+
+	// The TenantID and TenantName fields are optional for the Identity V2 API.
+	// Some providers allow you to specify a TenantName instead of the TenantId.
+	// Some require both. Your provider's authentication policies will determine
+	// how these fields influence authentication.
+	TenantID   string `json:"tenantId,omitempty"`
+	TenantName string `json:"tenantName,omitempty"`
+
+	// AllowReauth should be set to true if you grant permission for Gophercloud to
+	// cache your credentials in memory, and to allow Gophercloud to attempt to
+	// re-authenticate automatically if/when your token expires.  If you set it to
+	// false, it will not cache these settings, but re-authentication will not be
+	// possible.  This setting defaults to false.
+	AllowReauth bool `json:"-"`
+
+	// TokenID allows users to authenticate (possibly as another user) with an
+	// authentication token ID.
+	TokenID string
+}
+
+// ToTokenV2CreateMap allows AuthOptions to satisfy the AuthOptionsBuilder
+// interface in the v2 tokens package
+func (opts AuthOptions) ToTokenV2CreateMap() (map[string]interface{}, error) {
+	// Populate the request map.
+	authMap := make(map[string]interface{})
+
+	if opts.Username != "" {
+		if opts.Password != "" {
+			authMap["passwordCredentials"] = map[string]interface{}{
+				"username": opts.Username,
+				"password": opts.Password,
+			}
+		} else {
+			return nil, ErrMissingInput{Argument: "Password"}
+		}
+	} else if opts.TokenID != "" {
+		authMap["token"] = map[string]interface{}{
+			"id": opts.TokenID,
+		}
+	} else {
+		return nil, ErrMissingInput{Argument: "Username"}
+	}
+
+	if opts.TenantID != "" {
+		authMap["tenantId"] = opts.TenantID
+	}
+	if opts.TenantName != "" {
+		authMap["tenantName"] = opts.TenantName
+	}
+
+	return map[string]interface{}{"auth": authMap}, nil
+}
diff --git a/auth_results.go b/auth_results.go
new file mode 100644
index 0000000..856a233
--- /dev/null
+++ b/auth_results.go
@@ -0,0 +1,14 @@
+package gophercloud
+
+import "time"
+
+// AuthResults [deprecated] is a leftover type from the v0.x days. It was
+// intended to describe common functionality among identity service results, but
+// is not actually used anywhere.
+type AuthResults interface {
+	// TokenID returns the token's ID value from the authentication response.
+	TokenID() (string, error)
+
+	// ExpiresAt retrieves the token's expiration time.
+	ExpiresAt() (time.Time, error)
+}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..fb81a9d
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,67 @@
+/*
+Package gophercloud provides a multi-vendor interface to OpenStack-compatible
+clouds. The library has a three-level hierarchy: providers, services, and
+resources.
+
+Provider structs represent the service providers that offer and manage a
+collection of services. Examples of providers include: OpenStack, Rackspace,
+HP. These are defined like so:
+
+  opts := gophercloud.AuthOptions{
+    IdentityEndpoint: "https://my-openstack.com:5000/v2.0",
+    Username: "{username}",
+    Password: "{password}",
+    TenantID: "{tenant_id}",
+  }
+
+  provider, err := openstack.AuthenticatedClient(opts)
+
+Service structs are specific to a provider and handle all of the logic and
+operations for a particular OpenStack service. Examples of services include:
+Compute, Object Storage, Block Storage. In order to define one, you need to
+pass in the parent provider, like so:
+
+  opts := gophercloud.EndpointOpts{Region: "RegionOne"}
+
+  client := openstack.NewComputeV2(provider, opts)
+
+Resource structs are the domain models that services make use of in order
+to work with and represent the state of API resources:
+
+  server, err := servers.Get(client, "{serverId}").Extract()
+
+Intermediate Result structs are returned for API operations, which allow
+generic access to the HTTP headers, response body, and any errors associated
+with the network transaction. To turn a result into a usable resource struct,
+you must call the Extract method which is chained to the response, or an
+Extract function from an applicable extension:
+
+  result := servers.Get(client, "{serverId}")
+
+  // Attempt to extract the disk configuration from the OS-DCF disk config
+  // extension:
+  config, err := diskconfig.ExtractGet(result)
+
+All requests that enumerate a collection return a Pager struct that is used to
+iterate through the results one page at a time. Use the EachPage method on that
+Pager to handle each successive Page in a closure, then use the appropriate
+extraction method from that request's package to interpret that Page as a slice
+of results:
+
+  err := servers.List(client, nil).EachPage(func (page pagination.Page) (bool, error) {
+    s, err := servers.ExtractServers(page)
+    if err != nil {
+      return false, err
+    }
+
+    // Handle the []servers.Server slice.
+
+    // Return "false" or an error to prematurely stop fetching new pages.
+    return true, nil
+  })
+
+This top-level package contains utility functions and data types that are used
+throughout the provider and service packages. Of particular note for end users
+are the AuthOptions and EndpointOpts structs.
+*/
+package gophercloud
diff --git a/endpoint_search.go b/endpoint_search.go
new file mode 100644
index 0000000..9887947
--- /dev/null
+++ b/endpoint_search.go
@@ -0,0 +1,76 @@
+package gophercloud
+
+// Availability indicates to whom a specific service endpoint is accessible:
+// the internet at large, internal networks only, or only to administrators.
+// Different identity services use different terminology for these. Identity v2
+// lists them as different kinds of URLs within the service catalog ("adminURL",
+// "internalURL", and "publicURL"), while v3 lists them as "Interfaces" in an
+// endpoint's response.
+type Availability string
+
+const (
+	// AvailabilityAdmin indicates that an endpoint is only available to
+	// administrators.
+	AvailabilityAdmin Availability = "admin"
+
+	// AvailabilityPublic indicates that an endpoint is available to everyone on
+	// the internet.
+	AvailabilityPublic Availability = "public"
+
+	// AvailabilityInternal indicates that an endpoint is only available within
+	// the cluster's internal network.
+	AvailabilityInternal Availability = "internal"
+)
+
+// EndpointOpts specifies search criteria used by queries against an
+// OpenStack service catalog. The options must contain enough information to
+// unambiguously identify one, and only one, endpoint within the catalog.
+//
+// Usually, these are passed to service client factory functions in a provider
+// package, like "rackspace.NewComputeV2()".
+type EndpointOpts struct {
+	// Type [required] is the service type for the client (e.g., "compute",
+	// "object-store"). Generally, this will be supplied by the service client
+	// function, but a user-given value will be honored if provided.
+	Type string
+
+	// Name [optional] is the service name for the client (e.g., "nova") as it
+	// appears in the service catalog. Services can have the same Type but a
+	// different Name, which is why both Type and Name are sometimes needed.
+	Name string
+
+	// Region [required] is the geographic region in which the endpoint resides,
+	// generally specifying which datacenter should house your resources.
+	// Required only for services that span multiple regions.
+	Region string
+
+	// Availability [optional] is the visibility of the endpoint to be returned.
+	// Valid types include the constants AvailabilityPublic, AvailabilityInternal,
+	// or AvailabilityAdmin from this package.
+	//
+	// Availability is not required, and defaults to AvailabilityPublic. Not all
+	// providers or services offer all Availability options.
+	Availability Availability
+}
+
+/*
+EndpointLocator is an internal function to be used by provider implementations.
+
+It provides an implementation that locates a single endpoint from a service
+catalog for a specific ProviderClient based on user-provided EndpointOpts. The
+provider then uses it to discover related ServiceClients.
+*/
+type EndpointLocator func(EndpointOpts) (string, error)
+
+// ApplyDefaults is an internal method to be used by provider implementations.
+//
+// It sets EndpointOpts fields if not already set, including a default type.
+// Currently, EndpointOpts.Availability defaults to the public endpoint.
+func (eo *EndpointOpts) ApplyDefaults(t string) {
+	if eo.Type == "" {
+		eo.Type = t
+	}
+	if eo.Availability == "" {
+		eo.Availability = AvailabilityPublic
+	}
+}
diff --git a/endpoint_search_test.go b/endpoint_search_test.go
new file mode 100644
index 0000000..462316c
--- /dev/null
+++ b/endpoint_search_test.go
@@ -0,0 +1,19 @@
+package gophercloud
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestApplyDefaultsToEndpointOpts(t *testing.T) {
+	eo := EndpointOpts{Availability: AvailabilityPublic}
+	eo.ApplyDefaults("compute")
+	expected := EndpointOpts{Availability: AvailabilityPublic, Type: "compute"}
+	th.CheckDeepEquals(t, expected, eo)
+
+	eo = EndpointOpts{Type: "compute"}
+	eo.ApplyDefaults("object-store")
+	expected = EndpointOpts{Availability: AvailabilityPublic, Type: "compute"}
+	th.CheckDeepEquals(t, expected, eo)
+}
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..978fcb5
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,276 @@
+package gophercloud
+
+import "fmt"
+
+// BaseError is an error type that all other error types embed.
+type BaseError struct {
+	DefaultErrString string
+	Info             string
+}
+
+func (e BaseError) Error() string {
+	e.DefaultErrString = "An error occurred while executing a Gophercloud request."
+	return e.choseErrString()
+}
+
+func (e BaseError) choseErrString() string {
+	if e.Info != "" {
+		return e.Info
+	}
+	return e.DefaultErrString
+}
+
+// ErrMissingInput is the error when input is required in a particular
+// situation but not provided by the user
+type ErrMissingInput struct {
+	BaseError
+	Argument string
+}
+
+func (e ErrMissingInput) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Missing input for argument [%s]", e.Argument)
+	return e.choseErrString()
+}
+
+// ErrInvalidInput is an error type used for most non-HTTP Gophercloud errors.
+type ErrInvalidInput struct {
+	ErrMissingInput
+	Value interface{}
+}
+
+func (e ErrInvalidInput) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Invalid input provided for argument [%s]: [%+v]", e.Argument, e.Value)
+	return e.choseErrString()
+}
+
+// ErrUnexpectedResponseCode is returned by the Request method when a response code other than
+// those listed in OkCodes is encountered.
+type ErrUnexpectedResponseCode struct {
+	BaseError
+	URL      string
+	Method   string
+	Expected []int
+	Actual   int
+	Body     []byte
+}
+
+func (e ErrUnexpectedResponseCode) Error() string {
+	e.DefaultErrString = fmt.Sprintf(
+		"Expected HTTP response code %v when accessing [%s %s], but got %d instead\n%s",
+		e.Expected, e.Method, e.URL, e.Actual, e.Body,
+	)
+	return e.choseErrString()
+}
+
+// ErrDefault400 is the default error type returned on a 400 HTTP response code.
+type ErrDefault400 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault401 is the default error type returned on a 401 HTTP response code.
+type ErrDefault401 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault404 is the default error type returned on a 404 HTTP response code.
+type ErrDefault404 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault405 is the default error type returned on a 405 HTTP response code.
+type ErrDefault405 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault408 is the default error type returned on a 408 HTTP response code.
+type ErrDefault408 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault429 is the default error type returned on a 429 HTTP response code.
+type ErrDefault429 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault500 is the default error type returned on a 500 HTTP response code.
+type ErrDefault500 struct {
+	ErrUnexpectedResponseCode
+}
+
+// ErrDefault503 is the default error type returned on a 503 HTTP response code.
+type ErrDefault503 struct {
+	ErrUnexpectedResponseCode
+}
+
+func (e ErrDefault400) Error() string {
+	return "Invalid request due to incorrect syntax or missing required parameters."
+}
+func (e ErrDefault401) Error() string {
+	return "Authentication failed"
+}
+func (e ErrDefault404) Error() string {
+	return "Resource not found"
+}
+func (e ErrDefault405) Error() string {
+	return "Method not allowed"
+}
+func (e ErrDefault408) Error() string {
+	return "The server timed out waiting for the request"
+}
+func (e ErrDefault429) Error() string {
+	return "Too many requests have been sent in a given amount of time. Pause" +
+		" requests, wait up to one minute, and try again."
+}
+func (e ErrDefault500) Error() string {
+	return "Internal Server Error"
+}
+func (e ErrDefault503) Error() string {
+	return "The service is currently unable to handle the request due to a temporary" +
+		" overloading or maintenance. This is a temporary condition. Try again later."
+}
+
+// Err400er is the interface resource error types implement to override the error message
+// from a 400 error.
+type Err400er interface {
+	Error400(ErrUnexpectedResponseCode) error
+}
+
+// Err401er is the interface resource error types implement to override the error message
+// from a 401 error.
+type Err401er interface {
+	Error401(ErrUnexpectedResponseCode) error
+}
+
+// Err404er is the interface resource error types implement to override the error message
+// from a 404 error.
+type Err404er interface {
+	Error404(ErrUnexpectedResponseCode) error
+}
+
+// Err405er is the interface resource error types implement to override the error message
+// from a 405 error.
+type Err405er interface {
+	Error405(ErrUnexpectedResponseCode) error
+}
+
+// Err408er is the interface resource error types implement to override the error message
+// from a 408 error.
+type Err408er interface {
+	Error408(ErrUnexpectedResponseCode) error
+}
+
+// Err429er is the interface resource error types implement to override the error message
+// from a 429 error.
+type Err429er interface {
+	Error429(ErrUnexpectedResponseCode) error
+}
+
+// Err500er is the interface resource error types implement to override the error message
+// from a 500 error.
+type Err500er interface {
+	Error500(ErrUnexpectedResponseCode) error
+}
+
+// Err503er is the interface resource error types implement to override the error message
+// from a 503 error.
+type Err503er interface {
+	Error503(ErrUnexpectedResponseCode) error
+}
+
+// ErrTimeOut is the error type returned when an operations times out.
+type ErrTimeOut struct {
+	BaseError
+}
+
+func (e ErrTimeOut) Error() string {
+	e.DefaultErrString = "A time out occurred"
+	return e.choseErrString()
+}
+
+// ErrUnableToReauthenticate is the error type returned when reauthentication fails.
+type ErrUnableToReauthenticate struct {
+	BaseError
+	ErrOriginal error
+}
+
+func (e ErrUnableToReauthenticate) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Unable to re-authenticate: %s", e.ErrOriginal)
+	return e.choseErrString()
+}
+
+// ErrErrorAfterReauthentication is the error type returned when reauthentication
+// succeeds, but an error occurs afterword (usually an HTTP error).
+type ErrErrorAfterReauthentication struct {
+	BaseError
+	ErrOriginal error
+}
+
+func (e ErrErrorAfterReauthentication) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Successfully re-authenticated, but got error executing request: %s", e.ErrOriginal)
+	return e.choseErrString()
+}
+
+// ErrServiceNotFound is returned when no service in a service catalog matches
+// the provided EndpointOpts. This is generally returned by provider service
+// factory methods like "NewComputeV2()" and can mean that a service is not
+// enabled for your account.
+type ErrServiceNotFound struct {
+	BaseError
+}
+
+func (e ErrServiceNotFound) Error() string {
+	e.DefaultErrString = "No suitable service could be found in the service catalog."
+	return e.choseErrString()
+}
+
+// ErrEndpointNotFound is returned when no available endpoints match the
+// provided EndpointOpts. This is also generally returned by provider service
+// factory methods, and usually indicates that a region was specified
+// incorrectly.
+type ErrEndpointNotFound struct {
+	BaseError
+}
+
+func (e ErrEndpointNotFound) Error() string {
+	e.DefaultErrString = "No suitable endpoint could be found in the service catalog."
+	return e.choseErrString()
+}
+
+// ErrResourceNotFound is the error when trying to retrieve a resource's
+// ID by name and the resource doesn't exist.
+type ErrResourceNotFound struct {
+	BaseError
+	Name         string
+	ResourceType string
+}
+
+func (e ErrResourceNotFound) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Unable to find %s with name %s", e.ResourceType, e.Name)
+	return e.choseErrString()
+}
+
+// ErrMultipleResourcesFound is the error when trying to retrieve a resource's
+// ID by name and multiple resources have the user-provided name.
+type ErrMultipleResourcesFound struct {
+	BaseError
+	Name         string
+	Count        int
+	ResourceType string
+}
+
+func (e ErrMultipleResourcesFound) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Found %d %ss matching %s", e.Count, e.ResourceType, e.Name)
+	return e.choseErrString()
+}
+
+// ErrUnexpectedType is the error when an unexpected type is encountered
+type ErrUnexpectedType struct {
+	BaseError
+	Expected string
+	Actual   string
+}
+
+func (e ErrUnexpectedType) Error() string {
+	e.DefaultErrString = fmt.Sprintf("Expected %s but got %s", e.Expected, e.Actual)
+	return e.choseErrString()
+}
diff --git a/openstack/auth_env.go b/openstack/auth_env.go
new file mode 100644
index 0000000..f6d2eb1
--- /dev/null
+++ b/openstack/auth_env.go
@@ -0,0 +1,52 @@
+package openstack
+
+import (
+	"os"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+var nilOptions = gophercloud.AuthOptions{}
+
+// AuthOptionsFromEnv fills out an identity.AuthOptions structure with the settings found on the various OpenStack
+// OS_* environment variables.  The following variables provide sources of truth: OS_AUTH_URL, OS_USERNAME,
+// OS_PASSWORD, OS_TENANT_ID, and OS_TENANT_NAME.  Of these, OS_USERNAME, OS_PASSWORD, and OS_AUTH_URL must
+// have settings, or an error will result.  OS_TENANT_ID and OS_TENANT_NAME are optional.
+func AuthOptionsFromEnv() (gophercloud.AuthOptions, error) {
+	authURL := os.Getenv("OS_AUTH_URL")
+	username := os.Getenv("OS_USERNAME")
+	userID := os.Getenv("OS_USERID")
+	password := os.Getenv("OS_PASSWORD")
+	tenantID := os.Getenv("OS_TENANT_ID")
+	tenantName := os.Getenv("OS_TENANT_NAME")
+	domainID := os.Getenv("OS_DOMAIN_ID")
+	domainName := os.Getenv("OS_DOMAIN_NAME")
+
+	if authURL == "" {
+		err := gophercloud.ErrMissingInput{Argument: "authURL"}
+		return nilOptions, err
+	}
+
+	if username == "" && userID == "" {
+		err := gophercloud.ErrMissingInput{Argument: "username"}
+		return nilOptions, err
+	}
+
+	if password == "" {
+		err := gophercloud.ErrMissingInput{Argument: "password"}
+		return nilOptions, err
+	}
+
+	ao := gophercloud.AuthOptions{
+		IdentityEndpoint: authURL,
+		UserID:           userID,
+		Username:         username,
+		Password:         password,
+		TenantID:         tenantID,
+		TenantName:       tenantName,
+		DomainID:         domainID,
+		DomainName:       domainName,
+	}
+
+	return ao, nil
+}
diff --git a/openstack/blockstorage/v1/apiversions/doc.go b/openstack/blockstorage/v1/apiversions/doc.go
new file mode 100644
index 0000000..e3af39f
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/doc.go
@@ -0,0 +1,3 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Block Storage service, code-named Cinder.
+package apiversions
diff --git a/openstack/blockstorage/v1/apiversions/requests.go b/openstack/blockstorage/v1/apiversions/requests.go
new file mode 100644
index 0000000..725c13a
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/requests.go
@@ -0,0 +1,20 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List lists all the Cinder API versions available to end-users.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, listURL(c), func(r pagination.PageResult) pagination.Page {
+		return APIVersionPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get will retrieve the volume type with the provided ID. To extract the volume
+// type from the result, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, v string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, v), &r.Body, nil)
+	return
+}
diff --git a/openstack/blockstorage/v1/apiversions/requests_test.go b/openstack/blockstorage/v1/apiversions/requests_test.go
new file mode 100644
index 0000000..3a14c99
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/requests_test.go
@@ -0,0 +1,138 @@
+package apiversions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `{
+			"versions": [
+				{
+					"status": "CURRENT",
+					"updated": "2012-01-04T11:33:21Z",
+					"id": "v1.0",
+					"links": [
+						{
+							"href": "http://23.253.228.211:8776/v1/",
+							"rel": "self"
+						}
+					]
+			    },
+				{
+					"status": "CURRENT",
+					"updated": "2012-11-21T11:33:21Z",
+					"id": "v2.0",
+					"links": [
+						{
+							"href": "http://23.253.228.211:8776/v2/",
+							"rel": "self"
+						}
+					]
+				}
+			]
+		}`)
+	})
+
+	count := 0
+
+	List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractAPIVersions(page)
+		th.AssertNoErr(t, err)
+
+		expected := []APIVersion{
+			APIVersion{
+				ID:      "v1.0",
+				Status:  "CURRENT",
+				Updated: "2012-01-04T11:33:21Z",
+			},
+			APIVersion{
+				ID:      "v2.0",
+				Status:  "CURRENT",
+				Updated: "2012-11-21T11:33:21Z",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertEquals(t, 1, count)
+}
+
+func TestAPIInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `{
+			"version": {
+				"status": "CURRENT",
+				"updated": "2012-01-04T11:33:21Z",
+				"media-types": [
+					{
+						"base": "application/xml",
+						"type": "application/vnd.openstack.volume+xml;version=1"
+					},
+					{
+						"base": "application/json",
+						"type": "application/vnd.openstack.volume+json;version=1"
+					}
+				],
+				"id": "v1.0",
+				"links": [
+					{
+						"href": "http://23.253.228.211:8776/v1/",
+						"rel": "self"
+					},
+					{
+						"href": "http://jorgew.github.com/block-storage-api/content/os-block-storage-1.0.pdf",
+						"type": "application/pdf",
+						"rel": "describedby"
+					},
+					{
+						"href": "http://docs.rackspacecloud.com/servers/api/v1.1/application.wadl",
+						"type": "application/vnd.sun.wadl+xml",
+						"rel": "describedby"
+					}
+				]
+			}
+		}`)
+	})
+
+	actual, err := Get(client.ServiceClient(), "v1").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := APIVersion{
+		ID:      "v1.0",
+		Status:  "CURRENT",
+		Updated: "2012-01-04T11:33:21Z",
+	}
+
+	th.AssertEquals(t, actual.ID, expected.ID)
+	th.AssertEquals(t, actual.Status, expected.Status)
+	th.AssertEquals(t, actual.Updated, expected.Updated)
+}
diff --git a/openstack/blockstorage/v1/apiversions/results.go b/openstack/blockstorage/v1/apiversions/results.go
new file mode 100644
index 0000000..f510c6d
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/results.go
@@ -0,0 +1,49 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Cinder.
+type APIVersion struct {
+	ID      string `json:"id"`      // unique identifier
+	Status  string `json:"status"`  // current status
+	Updated string `json:"updated"` // date last updated
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+	is, err := ExtractAPIVersions(r)
+	return len(is) == 0, err
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(r pagination.Page) ([]APIVersion, error) {
+	var s struct {
+		Versions []APIVersion `json:"versions"`
+	}
+	err := (r.(APIVersionPage)).ExtractInto(&s)
+	return s.Versions, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts an API version resource.
+func (r GetResult) Extract() (*APIVersion, error) {
+	var s struct {
+		Version *APIVersion `json:"version"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Version, err
+}
diff --git a/openstack/blockstorage/v1/apiversions/urls.go b/openstack/blockstorage/v1/apiversions/urls.go
new file mode 100644
index 0000000..936f1c9
--- /dev/null
+++ b/openstack/blockstorage/v1/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+func getURL(c *gophercloud.ServiceClient, version string) string {
+	return c.ServiceURL(strings.TrimRight(version, "/") + "/")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("")
+}
diff --git a/openstack/blockstorage/v1/snapshots/doc.go b/openstack/blockstorage/v1/snapshots/doc.go
new file mode 100644
index 0000000..198f830
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/doc.go
@@ -0,0 +1,5 @@
+// Package snapshots provides information and interaction with snapshots in the
+// OpenStack Block Storage service. A snapshot is a point in time copy of the
+// data contained in an external storage volume, and can be controlled
+// programmatically.
+package snapshots
diff --git a/openstack/blockstorage/v1/snapshots/fixtures.go b/openstack/blockstorage/v1/snapshots/fixtures.go
new file mode 100644
index 0000000..b1bfef8
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/fixtures.go
@@ -0,0 +1,114 @@
+package snapshots
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+	th.Mux.HandleFunc("/snapshots", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+    {
+      "snapshots": [
+        {
+          "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+          "display_name": "snapshot-001"
+        },
+        {
+          "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+          "display_name": "snapshot-002"
+        }
+      ]
+    }
+    `)
+	})
+}
+
+func MockGetResponse(t *testing.T) {
+	th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+{
+    "snapshot": {
+        "display_name": "snapshot-001",
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+      `)
+	})
+}
+
+func MockCreateResponse(t *testing.T) {
+	th.Mux.HandleFunc("/snapshots", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "snapshot": {
+        "volume_id": "1234",
+        "display_name": "snapshot-001"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "snapshot": {
+        "volume_id": "1234",
+        "display_name": "snapshot-001",
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+    `)
+	})
+}
+
+func MockUpdateMetadataResponse(t *testing.T) {
+	th.Mux.HandleFunc("/snapshots/123/metadata", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestJSONRequest(t, r, `
+    {
+      "metadata": {
+        "key": "v1"
+      }
+    }
+    `)
+
+		fmt.Fprintf(w, `
+      {
+        "metadata": {
+          "key": "v1"
+        }
+      }
+    `)
+	})
+}
+
+func MockDeleteResponse(t *testing.T) {
+	th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/blockstorage/v1/snapshots/requests.go b/openstack/blockstorage/v1/snapshots/requests.go
new file mode 100644
index 0000000..cb9d0d0
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/requests.go
@@ -0,0 +1,158 @@
+package snapshots
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToSnapshotCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a Snapshot. This object is passed to
+// the snapshots.Create function. For more information about these parameters,
+// see the Snapshot object.
+type CreateOpts struct {
+	VolumeID    string                 `json:"volume_id" required:"true"`
+	Description string                 `json:"display_description,omitempty"`
+	Force       bool                   `json:"force,omitempty"`
+	Metadata    map[string]interface{} `json:"metadata,omitempty"`
+	Name        string                 `json:"display_name,omitempty"`
+}
+
+// ToSnapshotCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToSnapshotCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "snapshot")
+}
+
+// Create will create a new Snapshot based on the values in CreateOpts. To
+// extract the Snapshot object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToSnapshotCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete will delete the existing Snapshot with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// Get retrieves the Snapshot with the provided ID. To extract the Snapshot
+// object from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+	ToSnapshotListQuery() (string, error)
+}
+
+// ListOpts hold options for listing Snapshots. It is passed to the
+// snapshots.List function.
+type ListOpts struct {
+	Name     string `q:"display_name"`
+	Status   string `q:"status"`
+	VolumeID string `q:"volume_id"`
+}
+
+// ToSnapshotListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToSnapshotListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns Snapshots optionally limited by the conditions provided in
+// ListOpts.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client)
+	if opts != nil {
+		query, err := opts.ToSnapshotListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return SnapshotPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// UpdateMetadataOptsBuilder allows extensions to add additional parameters to
+// the Update request.
+type UpdateMetadataOptsBuilder interface {
+	ToSnapshotUpdateMetadataMap() (map[string]interface{}, error)
+}
+
+// UpdateMetadataOpts contain options for updating an existing Snapshot. This
+// object is passed to the snapshots.Update function. For more information
+// about the parameters, see the Snapshot object.
+type UpdateMetadataOpts struct {
+	Metadata map[string]interface{} `json:"metadata,omitempty"`
+}
+
+// ToSnapshotUpdateMetadataMap assembles a request body based on the contents of
+// an UpdateMetadataOpts.
+func (opts UpdateMetadataOpts) ToSnapshotUpdateMetadataMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// UpdateMetadata will update the Snapshot with provided information. To
+// extract the updated Snapshot from the response, call the ExtractMetadata
+// method on the UpdateMetadataResult.
+func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts UpdateMetadataOptsBuilder) (r UpdateMetadataResult) {
+	b, err := opts.ToSnapshotUpdateMetadataMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(updateMetadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// IDFromName is a convienience function that returns a snapshot's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractSnapshots(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "snapshot"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "snapshot"}
+	}
+}
diff --git a/openstack/blockstorage/v1/snapshots/requests_test.go b/openstack/blockstorage/v1/snapshots/requests_test.go
new file mode 100644
index 0000000..410b753
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/requests_test.go
@@ -0,0 +1,104 @@
+package snapshots
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockListResponse(t)
+
+	count := 0
+
+	List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractSnapshots(page)
+		if err != nil {
+			t.Errorf("Failed to extract snapshots: %v", err)
+			return false, err
+		}
+
+		expected := []Snapshot{
+			Snapshot{
+				ID:   "289da7f8-6440-407c-9fb4-7db01ec49164",
+				Name: "snapshot-001",
+			},
+			Snapshot{
+				ID:   "96c3bda7-c82a-4f50-be73-ca7621794835",
+				Name: "snapshot-002",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockGetResponse(t)
+
+	v, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, v.Name, "snapshot-001")
+	th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockCreateResponse(t)
+
+	options := CreateOpts{VolumeID: "1234", Name: "snapshot-001"}
+	n, err := Create(client.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.VolumeID, "1234")
+	th.AssertEquals(t, n.Name, "snapshot-001")
+	th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestUpdateMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockUpdateMetadataResponse(t)
+
+	expected := map[string]interface{}{"key": "v1"}
+
+	options := &UpdateMetadataOpts{
+		Metadata: map[string]interface{}{
+			"key": "v1",
+		},
+	}
+
+	actual, err := UpdateMetadata(client.ServiceClient(), "123", options).ExtractMetadata()
+
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, actual, expected)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockDeleteResponse(t)
+
+	res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/blockstorage/v1/snapshots/results.go b/openstack/blockstorage/v1/snapshots/results.go
new file mode 100644
index 0000000..195200d
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/results.go
@@ -0,0 +1,110 @@
+package snapshots
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Snapshot contains all the information associated with an OpenStack Snapshot.
+type Snapshot struct {
+	// Currect status of the Snapshot.
+	Status string `json:"status"`
+
+	// Display name.
+	Name string `json:"display_name"`
+
+	// Instances onto which the Snapshot is attached.
+	Attachments []string `json:"attachments"`
+
+	// Logical group.
+	AvailabilityZone string `json:"availability_zone"`
+
+	// Is the Snapshot bootable?
+	Bootable string `json:"bootable"`
+
+	// Date created.
+	CreatedAt gophercloud.JSONRFC3339Milli `json:"created_at"`
+
+	// Display description.
+	Description string `json:"display_discription"`
+
+	// See VolumeType object for more information.
+	VolumeType string `json:"volume_type"`
+
+	// ID of the Snapshot from which this Snapshot was created.
+	SnapshotID string `json:"snapshot_id"`
+
+	// ID of the Volume from which this Snapshot was created.
+	VolumeID string `json:"volume_id"`
+
+	// User-defined key-value pairs.
+	Metadata map[string]string `json:"metadata"`
+
+	// Unique identifier.
+	ID string `json:"id"`
+
+	// Size of the Snapshot, in GB.
+	Size int `json:"size"`
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// SnapshotPage is a pagination.Pager that is returned from a call to the List function.
+type SnapshotPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a SnapshotPage contains no Snapshots.
+func (r SnapshotPage) IsEmpty() (bool, error) {
+	volumes, err := ExtractSnapshots(r)
+	return len(volumes) == 0, err
+}
+
+// ExtractSnapshots extracts and returns Snapshots. It is used while iterating over a snapshots.List call.
+func ExtractSnapshots(r pagination.Page) ([]Snapshot, error) {
+	var s struct {
+		Snapshots []Snapshot `json:"snapshots"`
+	}
+	err := (r.(SnapshotPage)).ExtractInto(&s)
+	return s.Snapshots, err
+}
+
+// UpdateMetadataResult contains the response body and error from an UpdateMetadata request.
+type UpdateMetadataResult struct {
+	commonResult
+}
+
+// ExtractMetadata returns the metadata from a response from snapshots.UpdateMetadata.
+func (r UpdateMetadataResult) ExtractMetadata() (map[string]interface{}, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	m := r.Body.(map[string]interface{})["metadata"]
+	return m.(map[string]interface{}), nil
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract will get the Snapshot object out of the commonResult object.
+func (r commonResult) Extract() (*Snapshot, error) {
+	var s struct {
+		Snapshot *Snapshot `json:"snapshot"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Snapshot, err
+}
diff --git a/openstack/blockstorage/v1/snapshots/urls.go b/openstack/blockstorage/v1/snapshots/urls.go
new file mode 100644
index 0000000..7780437
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/urls.go
@@ -0,0 +1,27 @@
+package snapshots
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("snapshots")
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("snapshots", id)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return deleteURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return createURL(c)
+}
+
+func metadataURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("snapshots", id, "metadata")
+}
+
+func updateMetadataURL(c *gophercloud.ServiceClient, id string) string {
+	return metadataURL(c, id)
+}
diff --git a/openstack/blockstorage/v1/snapshots/util.go b/openstack/blockstorage/v1/snapshots/util.go
new file mode 100644
index 0000000..40fbb82
--- /dev/null
+++ b/openstack/blockstorage/v1/snapshots/util.go
@@ -0,0 +1,22 @@
+package snapshots
+
+import (
+	"github.com/gophercloud/gophercloud"
+)
+
+// WaitForStatus will continually poll the resource, checking for a particular
+// status. It will do this for the amount of seconds defined.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		current, err := Get(c, id).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if current.Status == status {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
diff --git a/openstack/blockstorage/v1/volumes/doc.go b/openstack/blockstorage/v1/volumes/doc.go
new file mode 100644
index 0000000..307b8b1
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/doc.go
@@ -0,0 +1,5 @@
+// Package volumes provides information and interaction with volumes in the
+// OpenStack Block Storage service. A volume is a detachable block storage
+// device, akin to a USB hard drive. It can only be attached to one instance at
+// a time.
+package volumes
diff --git a/openstack/blockstorage/v1/volumes/requests.go b/openstack/blockstorage/v1/volumes/requests.go
new file mode 100644
index 0000000..9850cfa
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/requests.go
@@ -0,0 +1,167 @@
+package volumes
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToVolumeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains options for creating a Volume. This object is passed to
+// the volumes.Create function. For more information about these parameters,
+// see the Volume object.
+type CreateOpts struct {
+	Size         int               `json:"size" required:"true"`
+	Availability string            `json:"availability,omitempty"`
+	Description  string            `json:"description,omitempty"`
+	Metadata     map[string]string `json:"metadata,omitempty"`
+	Name         string            `json:"name,omitempty"`
+	SnapshotID   string            `json:"snapshot_id,omitempty"`
+	SourceVolID  string            `json:"source_volid,omitempty"`
+	ImageID      string            `json:"imageRef,omitempty"`
+	VolumeType   string            `json:"volume_type,omitempty"`
+}
+
+// ToVolumeCreateMap assembles a request body based on the contents of a
+// CreateOpts.
+func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// Create will create a new Volume based on the values in CreateOpts. To extract
+// the Volume object from the response, call the Extract method on the
+// CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToVolumeCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete will delete the existing Volume with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// Get retrieves the Volume with the provided ID. To extract the Volume object
+// from the response, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+	ToVolumeListQuery() (string, error)
+}
+
+// ListOpts holds options for listing Volumes. It is passed to the volumes.List
+// function.
+type ListOpts struct {
+	// admin-only option. Set it to true to see all tenant volumes.
+	AllTenants bool `q:"all_tenants"`
+	// List only volumes that contain Metadata.
+	Metadata map[string]string `q:"metadata"`
+	// List only volumes that have Name as the display name.
+	Name string `q:"name"`
+	// List only volumes that have a status of Status.
+	Status string `q:"status"`
+}
+
+// ToVolumeListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToVolumeListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns Volumes optionally limited by the conditions provided in ListOpts.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client)
+	if opts != nil {
+		query, err := opts.ToVolumeListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return VolumePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+	ToVolumeUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contain options for updating an existing Volume. This object is passed
+// to the volumes.Update function. For more information about the parameters, see
+// the Volume object.
+type UpdateOpts struct {
+	Name        string            `json:"name,omitempty"`
+	Description string            `json:"description,omitempty"`
+	Metadata    map[string]string `json:"metadata,omitempty"`
+}
+
+// ToVolumeUpdateMap assembles a request body based on the contents of an
+// UpdateOpts.
+func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "volume")
+}
+
+// Update will update the Volume with provided information. To extract the updated
+// Volume from the response, call the Extract method on the UpdateResult.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToVolumeUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractVolumes(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "volume"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "volume"}
+	}
+}
diff --git a/openstack/blockstorage/v1/volumes/requests_test.go b/openstack/blockstorage/v1/volumes/requests_test.go
new file mode 100644
index 0000000..436cfdc
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/requests_test.go
@@ -0,0 +1,150 @@
+package volumes
+
+import (
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	fixtures "github.com/gophercloud/gophercloud/openstack/blockstorage/v1/volumes/testing"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockListResponse(t)
+
+	count := 0
+
+	List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVolumes(page)
+		if err != nil {
+			t.Errorf("Failed to extract volumes: %v", err)
+			return false, err
+		}
+
+		expected := []Volume{
+			Volume{
+				ID:   "289da7f8-6440-407c-9fb4-7db01ec49164",
+				Name: "vol-001",
+			},
+			Volume{
+				ID:   "96c3bda7-c82a-4f50-be73-ca7621794835",
+				Name: "vol-002",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestListAll(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockListResponse(t)
+
+	allPages, err := List(client.ServiceClient(), &ListOpts{}).AllPages()
+	th.AssertNoErr(t, err)
+	actual, err := ExtractVolumes(allPages)
+	th.AssertNoErr(t, err)
+
+	expected := []Volume{
+		Volume{
+			ID:   "289da7f8-6440-407c-9fb4-7db01ec49164",
+			Name: "vol-001",
+		},
+		Volume{
+			ID:   "96c3bda7-c82a-4f50-be73-ca7621794835",
+			Name: "vol-002",
+		},
+	}
+
+	th.CheckDeepEquals(t, expected, actual)
+
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockGetResponse(t)
+
+	actual, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Volume{
+		Status: "active",
+		Name:   "vol-001",
+		Attachments: []map[string]interface{}{
+			{
+				"attachment_id": "03987cd1-0ad5-40d1-9b2a-7cc48295d4fa",
+				"id":            "47e9ecc5-4045-4ee3-9a4b-d859d546a0cf",
+				"volume_id":     "6c80f8ac-e3e2-480c-8e6e-f1db92fe4bfe",
+				"server_id":     "d1c4788b-9435-42e2-9b81-29f3be1cd01f",
+				"host_name":     "mitaka",
+				"device":        "/",
+			},
+		},
+		AvailabilityZone: "us-east1",
+		Bootable:         "false",
+		CreatedAt:        gophercloud.JSONRFC3339Milli(time.Date(2012, 2, 14, 20, 53, 07, 0, time.UTC)),
+		Description:      "Another volume.",
+		VolumeType:       "289da7f8-6440-407c-9fb4-7db01ec49164",
+		SnapshotID:       "",
+		SourceVolID:      "",
+		Metadata: map[string]string{
+			"contents": "junk",
+		},
+		ID:   "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+		Size: 30,
+	}
+
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockCreateResponse(t)
+
+	options := &CreateOpts{Size: 75}
+	n, err := Create(client.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Size, 4)
+	th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockDeleteResponse(t)
+
+	res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	fixtures.MockUpdateResponse(t)
+
+	options := UpdateOpts{Name: "vol-002"}
+	v, err := Update(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22", options).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, "vol-002", v.Name)
+}
diff --git a/openstack/blockstorage/v1/volumes/results.go b/openstack/blockstorage/v1/volumes/results.go
new file mode 100644
index 0000000..09d1ba6
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/results.go
@@ -0,0 +1,101 @@
+package volumes
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Volume contains all the information associated with an OpenStack Volume.
+type Volume struct {
+	// Current status of the volume.
+	Status string `json:"status"`
+
+	// Human-readable display name for the volume.
+	Name string `json:"display_name"`
+
+	// Instances onto which the volume is attached.
+	Attachments []map[string]interface{} `json:"attachments"`
+
+	// This parameter is no longer used.
+	AvailabilityZone string `json:"availability_zone"`
+
+	// Indicates whether this is a bootable volume.
+	Bootable string `json:"bootable"`
+
+	// The date when this volume was created.
+	CreatedAt gophercloud.JSONRFC3339Milli `json:"created_at"`
+
+	// Human-readable description for the volume.
+	Description string `json:"display_description"`
+
+	// The type of volume to create, either SATA or SSD.
+	VolumeType string `json:"volume_type"`
+
+	// The ID of the snapshot from which the volume was created
+	SnapshotID string `json:"snapshot_id"`
+
+	// The ID of another block storage volume from which the current volume was created
+	SourceVolID string `json:"source_volid"`
+
+	// Arbitrary key-value pairs defined by the user.
+	Metadata map[string]string `json:"metadata"`
+
+	// Unique identifier for the volume.
+	ID string `json:"id"`
+
+	// Size of the volume in GB.
+	Size int `json:"size"`
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult contains the response body and error from a Delete request.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// VolumePage is a pagination.pager that is returned from a call to the List function.
+type VolumePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a VolumePage contains no Volumes.
+func (r VolumePage) IsEmpty() (bool, error) {
+	volumes, err := ExtractVolumes(r)
+	return len(volumes) == 0, err
+}
+
+// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call.
+func ExtractVolumes(r pagination.Page) ([]Volume, error) {
+	var s struct {
+		Volumes []Volume `json:"volumes"`
+	}
+	err := (r.(VolumePage)).ExtractInto(&s)
+	return s.Volumes, err
+}
+
+// UpdateResult contains the response body and error from an Update request.
+type UpdateResult struct {
+	commonResult
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract will get the Volume object out of the commonResult object.
+func (r commonResult) Extract() (*Volume, error) {
+	var s struct {
+		Volume *Volume `json:"volume"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Volume, err
+}
diff --git a/openstack/blockstorage/v1/volumes/testing/doc.go b/openstack/blockstorage/v1/volumes/testing/doc.go
new file mode 100644
index 0000000..60fc22a
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/doc.go
@@ -0,0 +1,7 @@
+/*
+This is package created is to hold fixtures (which imports testing),
+so that importing volumes package does not inadvertently import testing into production code
+More information here:
+https://github.com/gophercloud/gophercloud/issues/473
+*/
+package testing
diff --git a/openstack/blockstorage/v1/volumes/testing/fixtures.go b/openstack/blockstorage/v1/volumes/testing/fixtures.go
new file mode 100644
index 0000000..421cbf4
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/testing/fixtures.go
@@ -0,0 +1,126 @@
+package testing
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+	th.Mux.HandleFunc("/volumes", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+  {
+    "volumes": [
+      {
+        "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+        "display_name": "vol-001"
+      },
+      {
+        "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+        "display_name": "vol-002"
+      }
+    ]
+  }
+  `)
+	})
+}
+
+func MockGetResponse(t *testing.T) {
+	th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+			{
+			    "volume": {
+			        "id": "521752a6-acf6-4b2d-bc7a-119f9148cd8c",
+			        "display_name": "vol-001",
+			        "display_description": "Another volume.",
+			        "status": "active",
+			        "size": 30,
+			        "volume_type": "289da7f8-6440-407c-9fb4-7db01ec49164",
+			        "metadata": {
+			            "contents": "junk"
+			        },
+			        "availability_zone": "us-east1",
+			        "bootable": "false",
+			        "snapshot_id": null,
+			        "attachments": [
+			            {
+			                "attachment_id": "03987cd1-0ad5-40d1-9b2a-7cc48295d4fa",
+			                "id": "47e9ecc5-4045-4ee3-9a4b-d859d546a0cf",
+			                "volume_id": "6c80f8ac-e3e2-480c-8e6e-f1db92fe4bfe",
+			                "server_id": "d1c4788b-9435-42e2-9b81-29f3be1cd01f",
+			                "host_name": "mitaka",
+			                "device": "/"
+			            }
+			        ],
+			        "created_at": "2012-02-14T20:53:07Z"
+			    }
+			}
+      `)
+	})
+}
+
+func MockCreateResponse(t *testing.T) {
+	th.Mux.HandleFunc("/volumes", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "volume": {
+        "size": 75
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "volume": {
+        "size": 4,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+    `)
+	})
+}
+
+func MockDeleteResponse(t *testing.T) {
+	th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+func MockUpdateResponse(t *testing.T) {
+	th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+    {
+      "volume": {
+        "display_name": "vol-002",
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+        }
+    }
+    `)
+	})
+}
diff --git a/openstack/blockstorage/v1/volumes/urls.go b/openstack/blockstorage/v1/volumes/urls.go
new file mode 100644
index 0000000..8a00f97
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/urls.go
@@ -0,0 +1,23 @@
+package volumes
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("volumes")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return createURL(c)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("volumes", id)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return deleteURL(c, id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return deleteURL(c, id)
+}
diff --git a/openstack/blockstorage/v1/volumes/util.go b/openstack/blockstorage/v1/volumes/util.go
new file mode 100644
index 0000000..e86c1b4
--- /dev/null
+++ b/openstack/blockstorage/v1/volumes/util.go
@@ -0,0 +1,22 @@
+package volumes
+
+import (
+	"github.com/gophercloud/gophercloud"
+)
+
+// WaitForStatus will continually poll the resource, checking for a particular
+// status. It will do this for the amount of seconds defined.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		current, err := Get(c, id).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if current.Status == status {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
diff --git a/openstack/blockstorage/v1/volumetypes/doc.go b/openstack/blockstorage/v1/volumetypes/doc.go
new file mode 100644
index 0000000..793084f
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/doc.go
@@ -0,0 +1,9 @@
+// Package volumetypes provides information and interaction with volume types
+// in the OpenStack Block Storage service. A volume type indicates the type of
+// a block storage volume, such as SATA, SCSCI, SSD, etc. These can be
+// customized or defined by the OpenStack admin.
+//
+// You can also define extra_specs associated with your volume types. For
+// instance, you could have a VolumeType=SATA, with extra_specs (RPM=10000,
+// RAID-Level=5) . Extra_specs are defined and customized by the admin.
+package volumetypes
diff --git a/openstack/blockstorage/v1/volumetypes/fixtures.go b/openstack/blockstorage/v1/volumetypes/fixtures.go
new file mode 100644
index 0000000..1969120
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/fixtures.go
@@ -0,0 +1,60 @@
+package volumetypes
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListResponse(t *testing.T) {
+	th.Mux.HandleFunc("/types", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+    {
+      "volume_types": [
+        {
+          "id": "289da7f8-6440-407c-9fb4-7db01ec49164",
+          "name": "vol-type-001",
+          "extra_specs": {
+            "capabilities": "gpu"
+            }
+        },
+        {
+          "id": "96c3bda7-c82a-4f50-be73-ca7621794835",
+          "name": "vol-type-002",
+          "extra_specs": {}
+        }
+      ]
+    }
+    `)
+	})
+}
+
+func MockGetResponse(t *testing.T) {
+	th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+{
+    "volume_type": {
+        "name": "vol-type-001",
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+    "extra_specs": {
+      "serverNumber": "2"
+    }
+    }
+}
+      `)
+	})
+}
diff --git a/openstack/blockstorage/v1/volumetypes/requests.go b/openstack/blockstorage/v1/volumetypes/requests.go
new file mode 100644
index 0000000..b95c09a
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/requests.go
@@ -0,0 +1,59 @@
+package volumetypes
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToVolumeTypeCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts are options for creating a volume type.
+type CreateOpts struct {
+	// See VolumeType.
+	ExtraSpecs map[string]interface{} `json:"extra_specs,omitempty"`
+	// See VolumeType.
+	Name string `json:"name,omitempty"`
+}
+
+// ToVolumeTypeCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToVolumeTypeCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "volume_type")
+}
+
+// Create will create a new volume. To extract the created volume type object,
+// call the Extract method on the CreateResult.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToVolumeTypeCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete will delete the volume type with the provided ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// Get will retrieve the volume type with the provided ID. To extract the volume
+// type from the result, call the Extract method on the GetResult.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// List returns all volume types.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return VolumeTypePage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/blockstorage/v1/volumetypes/requests_test.go b/openstack/blockstorage/v1/volumetypes/requests_test.go
new file mode 100644
index 0000000..72113ec
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/requests_test.go
@@ -0,0 +1,118 @@
+package volumetypes
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockListResponse(t)
+
+	count := 0
+
+	List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVolumeTypes(page)
+		if err != nil {
+			t.Errorf("Failed to extract volume types: %v", err)
+			return false, err
+		}
+
+		expected := []VolumeType{
+			VolumeType{
+				ID:   "289da7f8-6440-407c-9fb4-7db01ec49164",
+				Name: "vol-type-001",
+				ExtraSpecs: map[string]interface{}{
+					"capabilities": "gpu",
+				},
+			},
+			VolumeType{
+				ID:         "96c3bda7-c82a-4f50-be73-ca7621794835",
+				Name:       "vol-type-002",
+				ExtraSpecs: map[string]interface{}{},
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockGetResponse(t)
+
+	vt, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, vt.ExtraSpecs, map[string]interface{}{"serverNumber": "2"})
+	th.AssertEquals(t, vt.Name, "vol-type-001")
+	th.AssertEquals(t, vt.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/types", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "volume_type": {
+        "name": "vol-type-001"
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "volume_type": {
+        "name": "vol-type-001",
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+		`)
+	})
+
+	options := &CreateOpts{Name: "vol-type-001"}
+	n, err := Create(client.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Name, "vol-type-001")
+	th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		w.WriteHeader(http.StatusAccepted)
+	})
+
+	err := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/blockstorage/v1/volumetypes/results.go b/openstack/blockstorage/v1/volumetypes/results.go
new file mode 100644
index 0000000..2c31238
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/results.go
@@ -0,0 +1,61 @@
+package volumetypes
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// VolumeType contains all information associated with an OpenStack Volume Type.
+type VolumeType struct {
+	ExtraSpecs map[string]interface{} `json:"extra_specs"` // user-defined metadata
+	ID         string                 `json:"id"`          // unique identifier
+	Name       string                 `json:"name"`        // display name
+}
+
+// CreateResult contains the response body and error from a Create request.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult contains the response body and error from a Get request.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult contains the response error from a Delete request.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// VolumeTypePage is a pagination.Pager that is returned from a call to the List function.
+type VolumeTypePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a VolumeTypePage contains no Volume Types.
+func (r VolumeTypePage) IsEmpty() (bool, error) {
+	volumeTypes, err := ExtractVolumeTypes(r)
+	return len(volumeTypes) == 0, err
+}
+
+// ExtractVolumeTypes extracts and returns Volume Types.
+func ExtractVolumeTypes(r pagination.Page) ([]VolumeType, error) {
+	var s struct {
+		VolumeTypes []VolumeType `json:"volume_types"`
+	}
+	err := (r.(VolumeTypePage)).ExtractInto(&s)
+	return s.VolumeTypes, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract will get the Volume Type object out of the commonResult object.
+func (r commonResult) Extract() (*VolumeType, error) {
+	var s struct {
+		VolumeType *VolumeType `json:"volume_type"`
+	}
+	err := r.ExtractInto(&s)
+	return s.VolumeType, err
+}
diff --git a/openstack/blockstorage/v1/volumetypes/urls.go b/openstack/blockstorage/v1/volumetypes/urls.go
new file mode 100644
index 0000000..822c7dd
--- /dev/null
+++ b/openstack/blockstorage/v1/volumetypes/urls.go
@@ -0,0 +1,19 @@
+package volumetypes
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("types")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return listURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("types", id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return getURL(c, id)
+}
diff --git a/openstack/cdn/v1/base/doc.go b/openstack/cdn/v1/base/doc.go
new file mode 100644
index 0000000..f78d4f7
--- /dev/null
+++ b/openstack/cdn/v1/base/doc.go
@@ -0,0 +1,4 @@
+// Package base provides information and interaction with the base API
+// resource in the OpenStack CDN service. This API resource allows for
+// retrieving the Home Document and pinging the root URL.
+package base
diff --git a/openstack/cdn/v1/base/fixtures.go b/openstack/cdn/v1/base/fixtures.go
new file mode 100644
index 0000000..f95d893
--- /dev/null
+++ b/openstack/cdn/v1/base/fixtures.go
@@ -0,0 +1,53 @@
+package base
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleGetSuccessfully creates an HTTP handler at `/` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+    {
+        "resources": {
+            "rel/cdn": {
+                "href-template": "services{?marker,limit}",
+                "href-vars": {
+                    "marker": "param/marker",
+                    "limit": "param/limit"
+                },
+                "hints": {
+                    "allow": [
+                        "GET"
+                    ],
+                    "formats": {
+                        "application/json": {}
+                    }
+                }
+            }
+        }
+    }
+    `)
+
+	})
+}
+
+// HandlePingSuccessfully creates an HTTP handler at `/ping` on the test handler
+// mux that responds with a `Ping` response.
+func HandlePingSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/cdn/v1/base/requests.go b/openstack/cdn/v1/base/requests.go
new file mode 100644
index 0000000..34d3b72
--- /dev/null
+++ b/openstack/cdn/v1/base/requests.go
@@ -0,0 +1,19 @@
+package base
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retrieves the home document, allowing the user to discover the
+// entire API.
+func Get(c *gophercloud.ServiceClient) (r GetResult) {
+	_, r.Err = c.Get(getURL(c), &r.Body, nil)
+	return
+}
+
+// Ping retrieves a ping to the server.
+func Ping(c *gophercloud.ServiceClient) (r PingResult) {
+	_, r.Err = c.Get(pingURL(c), nil, &gophercloud.RequestOpts{
+		OkCodes:     []int{204},
+		MoreHeaders: map[string]string{"Accept": ""},
+	})
+	return
+}
diff --git a/openstack/cdn/v1/base/requests_test.go b/openstack/cdn/v1/base/requests_test.go
new file mode 100644
index 0000000..a0559e3
--- /dev/null
+++ b/openstack/cdn/v1/base/requests_test.go
@@ -0,0 +1,43 @@
+package base
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetHomeDocument(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(fake.ServiceClient()).Extract()
+	th.CheckNoErr(t, err)
+
+	expected := HomeDocument{
+		"rel/cdn": map[string]interface{}{
+			"href-template": "services{?marker,limit}",
+			"href-vars": map[string]interface{}{
+				"marker": "param/marker",
+				"limit":  "param/limit",
+			},
+			"hints": map[string]interface{}{
+				"allow": []string{"GET"},
+				"formats": map[string]interface{}{
+					"application/json": map[string]interface{}{},
+				},
+			},
+		},
+	}
+	th.CheckDeepEquals(t, expected, *actual)
+}
+
+func TestPing(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePingSuccessfully(t)
+
+	err := Ping(fake.ServiceClient()).ExtractErr()
+	th.CheckNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/base/results.go b/openstack/cdn/v1/base/results.go
new file mode 100644
index 0000000..2dfde7d
--- /dev/null
+++ b/openstack/cdn/v1/base/results.go
@@ -0,0 +1,23 @@
+package base
+
+import "github.com/gophercloud/gophercloud"
+
+// HomeDocument is a resource that contains all the resources for the CDN API.
+type HomeDocument map[string]interface{}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a home document resource.
+func (r GetResult) Extract() (*HomeDocument, error) {
+	var s HomeDocument
+	err := r.ExtractInto(&s)
+	return &s, err
+}
+
+// PingResult represents the result of a Ping operation.
+type PingResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/base/urls.go b/openstack/cdn/v1/base/urls.go
new file mode 100644
index 0000000..07d892b
--- /dev/null
+++ b/openstack/cdn/v1/base/urls.go
@@ -0,0 +1,11 @@
+package base
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL()
+}
+
+func pingURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("ping")
+}
diff --git a/openstack/cdn/v1/flavors/doc.go b/openstack/cdn/v1/flavors/doc.go
new file mode 100644
index 0000000..d406698
--- /dev/null
+++ b/openstack/cdn/v1/flavors/doc.go
@@ -0,0 +1,6 @@
+// Package flavors provides information and interaction with the flavors API
+// resource in the OpenStack CDN service. This API resource allows for
+// listing flavors and retrieving a specific flavor.
+//
+// A flavor is a mapping configuration to a CDN provider.
+package flavors
diff --git a/openstack/cdn/v1/flavors/fixtures.go b/openstack/cdn/v1/flavors/fixtures.go
new file mode 100644
index 0000000..285075e
--- /dev/null
+++ b/openstack/cdn/v1/flavors/fixtures.go
@@ -0,0 +1,82 @@
+package flavors
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleListCDNFlavorsSuccessfully creates an HTTP handler at `/flavors` on the test handler mux
+// that responds with a `List` response.
+func HandleListCDNFlavorsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/flavors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+      {
+        "flavors": [
+            {
+                "id": "europe",
+                "providers": [
+                    {
+                        "provider": "Fastly",
+                        "links": [
+                            {
+                                "href": "http://www.fastly.com",
+                                "rel": "provider_url"
+                            }
+                        ]
+                    }
+                ],
+                "links": [
+                    {
+                        "href": "https://www.poppycdn.io/v1.0/flavors/europe",
+                        "rel": "self"
+                    }
+                ]
+            }
+        ]
+    }
+    `)
+	})
+}
+
+// HandleGetCDNFlavorSuccessfully creates an HTTP handler at `/flavors/{id}` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetCDNFlavorSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/flavors/asia", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+      {
+          "id" : "asia",
+          "providers" : [
+              {
+                  "provider" : "ChinaCache",
+                  "links": [
+                      {
+                          "href": "http://www.chinacache.com",
+                          "rel": "provider_url"
+                      }
+                  ]
+              }
+          ],
+          "links": [
+              {
+                  "href": "https://www.poppycdn.io/v1.0/flavors/asia",
+                  "rel": "self"
+              }
+          ]
+      }
+    `)
+	})
+}
diff --git a/openstack/cdn/v1/flavors/requests.go b/openstack/cdn/v1/flavors/requests.go
new file mode 100644
index 0000000..1977fe3
--- /dev/null
+++ b/openstack/cdn/v1/flavors/requests.go
@@ -0,0 +1,19 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a single page of CDN flavors.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, listURL(c), func(r pagination.PageResult) pagination.Page {
+		return FlavorPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get retrieves a specific flavor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+	return
+}
diff --git a/openstack/cdn/v1/flavors/requests_test.go b/openstack/cdn/v1/flavors/requests_test.go
new file mode 100644
index 0000000..0aeda00
--- /dev/null
+++ b/openstack/cdn/v1/flavors/requests_test.go
@@ -0,0 +1,89 @@
+package flavors
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleListCDNFlavorsSuccessfully(t)
+
+	count := 0
+
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractFlavors(page)
+		if err != nil {
+			t.Errorf("Failed to extract flavors: %v", err)
+			return false, err
+		}
+
+		expected := []Flavor{
+			Flavor{
+				ID: "europe",
+				Providers: []Provider{
+					Provider{
+						Provider: "Fastly",
+						Links: []gophercloud.Link{
+							gophercloud.Link{
+								Href: "http://www.fastly.com",
+								Rel:  "provider_url",
+							},
+						},
+					},
+				},
+				Links: []gophercloud.Link{
+					gophercloud.Link{
+						Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+						Rel:  "self",
+					},
+				},
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleGetCDNFlavorSuccessfully(t)
+
+	expected := &Flavor{
+		ID: "asia",
+		Providers: []Provider{
+			Provider{
+				Provider: "ChinaCache",
+				Links: []gophercloud.Link{
+					gophercloud.Link{
+						Href: "http://www.chinacache.com",
+						Rel:  "provider_url",
+					},
+				},
+			},
+		},
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+				Rel:  "self",
+			},
+		},
+	}
+
+	actual, err := Get(fake.ServiceClient(), "asia").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/cdn/v1/flavors/results.go b/openstack/cdn/v1/flavors/results.go
new file mode 100644
index 0000000..02c2851
--- /dev/null
+++ b/openstack/cdn/v1/flavors/results.go
@@ -0,0 +1,60 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Provider represents a provider for a particular flavor.
+type Provider struct {
+	// Specifies the name of the provider. The name must not exceed 64 bytes in
+	// length and is limited to unicode, digits, underscores, and hyphens.
+	Provider string `json:"provider"`
+	// Specifies a list with an href where rel is provider_url.
+	Links []gophercloud.Link `json:"links"`
+}
+
+// Flavor represents a mapping configuration to a CDN provider.
+type Flavor struct {
+	// Specifies the name of the flavor. The name must not exceed 64 bytes in
+	// length and is limited to unicode, digits, underscores, and hyphens.
+	ID string `json:"id"`
+	// Specifies the list of providers mapped to this flavor.
+	Providers []Provider `json:"providers"`
+	// Specifies the self-navigating JSON document paths.
+	Links []gophercloud.Link `json:"links"`
+}
+
+// FlavorPage is the page returned by a pager when traversing over a
+// collection of CDN flavors.
+type FlavorPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a FlavorPage contains no Flavors.
+func (r FlavorPage) IsEmpty() (bool, error) {
+	flavors, err := ExtractFlavors(r)
+	return len(flavors) == 0, err
+}
+
+// ExtractFlavors extracts and returns Flavors. It is used while iterating over
+// a flavors.List call.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+	var s struct {
+		Flavors []Flavor `json:"flavors"`
+	}
+	err := (r.(FlavorPage)).ExtractInto(&s)
+	return s.Flavors, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that extracts a flavor from a GetResult.
+func (r GetResult) Extract() (*Flavor, error) {
+	var s *Flavor
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/cdn/v1/flavors/urls.go b/openstack/cdn/v1/flavors/urls.go
new file mode 100644
index 0000000..a8540a2
--- /dev/null
+++ b/openstack/cdn/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("flavors")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("flavors", id)
+}
diff --git a/openstack/cdn/v1/serviceassets/doc.go b/openstack/cdn/v1/serviceassets/doc.go
new file mode 100644
index 0000000..ceecaa5
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/doc.go
@@ -0,0 +1,7 @@
+// Package serviceassets provides information and interaction with the
+// serviceassets API resource in the OpenStack CDN service. This API resource
+// allows for deleting cached assets.
+//
+// A service distributes assets across the network. Service assets let you
+// interrogate properties about these assets and perform certain actions on them.
+package serviceassets
diff --git a/openstack/cdn/v1/serviceassets/fixtures.go b/openstack/cdn/v1/serviceassets/fixtures.go
new file mode 100644
index 0000000..9c62514
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/fixtures.go
@@ -0,0 +1,19 @@
+package serviceassets
+
+import (
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleDeleteCDNAssetSuccessfully creates an HTTP handler at `/services/{id}/assets` on the test handler mux
+// that responds with a `Delete` response.
+func HandleDeleteCDNAssetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0/assets", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/cdn/v1/serviceassets/requests.go b/openstack/cdn/v1/serviceassets/requests.go
new file mode 100644
index 0000000..80c908f
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/requests.go
@@ -0,0 +1,51 @@
+package serviceassets
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// DeleteOptsBuilder allows extensions to add additional parameters to the Delete
+// request.
+type DeleteOptsBuilder interface {
+	ToCDNAssetDeleteParams() (string, error)
+}
+
+// DeleteOpts is a structure that holds options for deleting CDN service assets.
+type DeleteOpts struct {
+	// If all is set to true, specifies that the delete occurs against all of the
+	// assets for the service.
+	All bool `q:"all"`
+	// Specifies the relative URL of the asset to be deleted.
+	URL string `q:"url"`
+}
+
+// ToCDNAssetDeleteParams formats a DeleteOpts into a query string.
+func (opts DeleteOpts) ToCDNAssetDeleteParams() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// Delete accepts a unique service ID or URL and deletes the CDN service asset associated with
+// it. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Delete(c *gophercloud.ServiceClient, idOrURL string, opts DeleteOptsBuilder) (r DeleteResult) {
+	var url string
+	if strings.Contains(idOrURL, "/") {
+		url = idOrURL
+	} else {
+		url = deleteURL(c, idOrURL)
+	}
+	if opts != nil {
+		q, err := opts.ToCDNAssetDeleteParams()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		url += q
+	}
+	_, r.Err = c.Delete(url, nil)
+	return
+}
diff --git a/openstack/cdn/v1/serviceassets/requests_test.go b/openstack/cdn/v1/serviceassets/requests_test.go
new file mode 100644
index 0000000..1204943
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/requests_test.go
@@ -0,0 +1,18 @@
+package serviceassets
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleDeleteCDNAssetSuccessfully(t)
+
+	err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", nil).ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/serviceassets/results.go b/openstack/cdn/v1/serviceassets/results.go
new file mode 100644
index 0000000..b6114c6
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/results.go
@@ -0,0 +1,8 @@
+package serviceassets
+
+import "github.com/gophercloud/gophercloud"
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/serviceassets/urls.go b/openstack/cdn/v1/serviceassets/urls.go
new file mode 100644
index 0000000..ce17418
--- /dev/null
+++ b/openstack/cdn/v1/serviceassets/urls.go
@@ -0,0 +1,7 @@
+package serviceassets
+
+import "github.com/gophercloud/gophercloud"
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("services", id, "assets")
+}
diff --git a/openstack/cdn/v1/services/doc.go b/openstack/cdn/v1/services/doc.go
new file mode 100644
index 0000000..41f7c60
--- /dev/null
+++ b/openstack/cdn/v1/services/doc.go
@@ -0,0 +1,7 @@
+// Package services provides information and interaction with the services API
+// resource in the OpenStack CDN service. This API resource allows for
+// listing, creating, updating, retrieving, and deleting services.
+//
+// A service represents an application that has its content cached to the edge
+// nodes.
+package services
diff --git a/openstack/cdn/v1/services/errors.go b/openstack/cdn/v1/services/errors.go
new file mode 100644
index 0000000..359584c
--- /dev/null
+++ b/openstack/cdn/v1/services/errors.go
@@ -0,0 +1,7 @@
+package services
+
+import "fmt"
+
+func no(str string) error {
+	return fmt.Errorf("Required parameter %s not provided", str)
+}
diff --git a/openstack/cdn/v1/services/fixtures.go b/openstack/cdn/v1/services/fixtures.go
new file mode 100644
index 0000000..c882f8c
--- /dev/null
+++ b/openstack/cdn/v1/services/fixtures.go
@@ -0,0 +1,372 @@
+package services
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleListCDNServiceSuccessfully creates an HTTP handler at `/services` on the test handler mux
+// that responds with a `List` response.
+func HandleListCDNServiceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, `
+    {
+        "links": [
+            {
+                "rel": "next",
+                "href": "https://www.poppycdn.io/v1.0/services?marker=96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0&limit=20"
+            }
+        ],
+        "services": [
+            {
+                "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+                "name": "mywebsite.com",
+                "domains": [
+                    {
+                        "domain": "www.mywebsite.com"
+                    }
+                ],
+                "origins": [
+                    {
+                        "origin": "mywebsite.com",
+                        "port": 80,
+                        "ssl": false
+                    }
+                ],
+                "caching": [
+                    {
+                        "name": "default",
+                        "ttl": 3600
+                    },
+                    {
+                        "name": "home",
+                        "ttl": 17200,
+                        "rules": [
+                            {
+                                "name": "index",
+                                "request_url": "/index.htm"
+                            }
+                        ]
+                    },
+                    {
+                        "name": "images",
+                        "ttl": 12800,
+                        "rules": [
+                            {
+                                "name": "images",
+                                "request_url": "*.png"
+                            }
+                        ]
+                    }
+                ],
+                "restrictions": [
+                    {
+                        "name": "website only",
+                        "rules": [
+                            {
+                                "name": "mywebsite.com",
+                                "referrer": "www.mywebsite.com"
+                            }
+                        ]
+                    }
+                ],
+                "flavor_id": "asia",
+                "status": "deployed",
+                "errors" : [],
+                "links": [
+                    {
+                        "href": "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+                        "rel": "self"
+                    },
+                    {
+                        "href": "mywebsite.com.cdn123.poppycdn.net",
+                        "rel": "access_url"
+                    },
+                    {
+                        "href": "https://www.poppycdn.io/v1.0/flavors/asia",
+                        "rel": "flavor"
+                    }
+                ]
+            },
+            {
+                "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+                "name": "myothersite.com",
+                "domains": [
+                    {
+                        "domain": "www.myothersite.com"
+                    }
+                ],
+                "origins": [
+                    {
+                        "origin": "44.33.22.11",
+                        "port": 80,
+                        "ssl": false
+                    },
+                    {
+                        "origin": "77.66.55.44",
+                        "port": 80,
+                        "ssl": false,
+                        "rules": [
+                            {
+                                "name": "videos",
+                                "request_url": "^/videos/*.m3u"
+                            }
+                        ]
+                    }
+                ],
+                "caching": [
+                    {
+                        "name": "default",
+                        "ttl": 3600
+                    }
+                ],
+                "restrictions": [
+                    {}
+                ],
+                "flavor_id": "europe",
+                "status": "deployed",
+                "links": [
+                    {
+                        "href": "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+                        "rel": "self"
+                    },
+                    {
+                        "href": "myothersite.com.poppycdn.net",
+                        "rel": "access_url"
+                    },
+                    {
+                        "href": "https://www.poppycdn.io/v1.0/flavors/europe",
+                        "rel": "flavor"
+                    }
+                ]
+            }
+        ]
+    }
+    `)
+		case "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1":
+			fmt.Fprintf(w, `{
+        "services": []
+    }`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// HandleCreateCDNServiceSuccessfully creates an HTTP handler at `/services` on the test handler mux
+// that responds with a `Create` response.
+func HandleCreateCDNServiceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestJSONRequest(t, r, `
+      {
+        "name": "mywebsite.com",
+        "domains": [
+            {
+                "domain": "www.mywebsite.com"
+            },
+            {
+                "domain": "blog.mywebsite.com"
+            }
+        ],
+        "origins": [
+            {
+                "origin": "mywebsite.com",
+                "port": 80,
+                "ssl": false
+            }
+        ],
+        "restrictions": [
+                         {
+                         "name": "website only",
+                         "rules": [
+                                   {
+                                   "name": "mywebsite.com",
+                                   "referrer": "www.mywebsite.com"
+                    }
+                ]
+            }
+        ],
+        "caching": [
+            {
+                "name": "default",
+                "ttl": 3600
+            }
+        ],
+
+        "flavor_id": "cdn"
+      }
+   `)
+		w.Header().Add("Location", "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleGetCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Get` response.
+func HandleGetCDNServiceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+    {
+        "id": "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+        "name": "mywebsite.com",
+        "domains": [
+            {
+                "domain": "www.mywebsite.com",
+                "protocol": "http"
+            }
+        ],
+        "origins": [
+            {
+                "origin": "mywebsite.com",
+                "port": 80,
+                "ssl": false
+            }
+        ],
+        "caching": [
+            {
+                "name": "default",
+                "ttl": 3600
+            },
+            {
+                "name": "home",
+                "ttl": 17200,
+                "rules": [
+                    {
+                        "name": "index",
+                        "request_url": "/index.htm"
+                    }
+                ]
+            },
+            {
+                "name": "images",
+                "ttl": 12800,
+                "rules": [
+                    {
+                        "name": "images",
+                        "request_url": "*.png"
+                    }
+                ]
+            }
+        ],
+        "restrictions": [
+            {
+                "name": "website only",
+                "rules": [
+                    {
+                        "name": "mywebsite.com",
+                        "referrer": "www.mywebsite.com"
+                    }
+                ]
+            }
+        ],
+        "flavor_id": "cdn",
+        "status": "deployed",
+        "errors" : [],
+        "links": [
+            {
+                "href": "https://global.cdn.api.rackspacecloud.com/v1.0/110011/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+                "rel": "self"
+            },
+            {
+                "href": "blog.mywebsite.com.cdn1.raxcdn.com",
+                "rel": "access_url"
+            },
+            {
+                "href": "https://global.cdn.api.rackspacecloud.com/v1.0/110011/flavors/cdn",
+                "rel": "flavor"
+            }
+        ]
+    }
+    `)
+	})
+}
+
+// HandleUpdateCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Update` response.
+func HandleUpdateCDNServiceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PATCH")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestJSONRequest(t, r, `
+      [
+				{
+					"op": "add",
+					"path": "/domains/-",
+					"value": {"domain": "appended.mocksite4.com"}
+				},
+				{
+					"op": "add",
+					"path": "/domains/4",
+					"value": {"domain": "inserted.mocksite4.com"}
+				},
+				{
+					"op": "add",
+					"path": "/domains",
+					"value": [
+						{"domain": "bulkadded1.mocksite4.com"},
+						{"domain": "bulkadded2.mocksite4.com"}
+					]
+				},
+				{
+					"op": "replace",
+					"path": "/origins/2",
+					"value": {"origin": "44.33.22.11", "port": 80, "ssl": false}
+				},
+				{
+					"op": "replace",
+					"path": "/origins",
+					"value": [
+						{"origin": "44.33.22.11", "port": 80, "ssl": false},
+						{"origin": "55.44.33.22", "port": 443, "ssl": true}
+					]
+				},
+				{
+					"op": "remove",
+					"path": "/caching/8"
+				},
+				{
+					"op": "remove",
+					"path": "/caching"
+				},
+				{
+					"op": "replace",
+					"path": "/name",
+					"value": "differentServiceName"
+				}
+    ]
+   `)
+		w.Header().Add("Location", "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleDeleteCDNServiceSuccessfully creates an HTTP handler at `/services/{id}` on the test handler mux
+// that responds with a `Delete` response.
+func HandleDeleteCDNServiceSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/cdn/v1/services/requests.go b/openstack/cdn/v1/services/requests.go
new file mode 100644
index 0000000..4c0c626
--- /dev/null
+++ b/openstack/cdn/v1/services/requests.go
@@ -0,0 +1,285 @@
+package services
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToCDNServiceListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Marker string `q:"marker"`
+	Limit  int    `q:"limit"`
+}
+
+// ToCDNServiceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToCDNServiceListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// CDN services. It accepts a ListOpts struct, which allows for pagination via
+// marker and limit.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToCDNServiceListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		p := ServicePage{pagination.MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	})
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToCDNServiceCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+	// Specifies the name of the service. The minimum length for name is
+	// 3. The maximum length is 256.
+	Name string `json:"name" required:"true"`
+	// Specifies a list of domains used by users to access their website.
+	Domains []Domain `json:"domains" required:"true"`
+	// Specifies a list of origin domains or IP addresses where the
+	// original assets are stored.
+	Origins []Origin `json:"origins" required:"true"`
+	// Specifies the CDN provider flavor ID to use. For a list of
+	// flavors, see the operation to list the available flavors. The minimum
+	// length for flavor_id is 1. The maximum length is 256.
+	FlavorID string `json:"flavor_id" required:"true"`
+	// Specifies the TTL rules for the assets under this service. Supports wildcards for fine-grained control.
+	Caching []CacheRule `json:"caching,omitempty"`
+	// Specifies the restrictions that define who can access assets (content from the CDN cache).
+	Restrictions []Restriction `json:"restrictions,omitempty"`
+}
+
+// ToCDNServiceCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToCDNServiceCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Create accepts a CreateOpts struct and creates a new CDN service using the
+// values provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToCDNServiceCreateMap()
+	if err != nil {
+		r.Err = err
+		return r
+	}
+	resp, err := c.Post(createURL(c), &b, nil, nil)
+	r.Header = resp.Header
+	r.Err = err
+	return
+}
+
+// Get retrieves a specific service based on its URL or its unique ID. For
+// example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Get(c *gophercloud.ServiceClient, idOrURL string) (r GetResult) {
+	var url string
+	if strings.Contains(idOrURL, "/") {
+		url = idOrURL
+	} else {
+		url = getURL(c, idOrURL)
+	}
+	_, r.Err = c.Get(url, &r.Body, nil)
+	return
+}
+
+// Path is a JSON pointer location that indicates which service parameter is being added, replaced,
+// or removed.
+type Path struct {
+	baseElement string
+}
+
+func (p Path) renderRoot() string {
+	return "/" + p.baseElement
+}
+
+func (p Path) renderDash() string {
+	return fmt.Sprintf("/%s/-", p.baseElement)
+}
+
+func (p Path) renderIndex(index int64) string {
+	return fmt.Sprintf("/%s/%d", p.baseElement, index)
+}
+
+var (
+	// PathDomains indicates that an update operation is to be performed on a Domain.
+	PathDomains = Path{baseElement: "domains"}
+
+	// PathOrigins indicates that an update operation is to be performed on an Origin.
+	PathOrigins = Path{baseElement: "origins"}
+
+	// PathCaching indicates that an update operation is to be performed on a CacheRule.
+	PathCaching = Path{baseElement: "caching"}
+)
+
+type value interface {
+	toPatchValue() interface{}
+	appropriatePath() Path
+	renderRootOr(func(p Path) string) string
+}
+
+// Patch represents a single update to an existing Service. Multiple updates to a service can be
+// submitted at the same time.
+type Patch interface {
+	ToCDNServiceUpdateMap() map[string]interface{}
+}
+
+// Insertion is a Patch that requests the addition of a value (Domain, Origin, or CacheRule) to
+// a Service at a fixed index. Use an Append instead to append the new value to the end of its
+// collection. Pass it to the Update function as part of the Patch slice.
+type Insertion struct {
+	Index int64
+	Value value
+}
+
+// ToCDNServiceUpdateMap converts an Insertion into a request body fragment suitable for the
+// Update call.
+func (opts Insertion) ToCDNServiceUpdateMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "add",
+		"path":  opts.Value.renderRootOr(func(p Path) string { return p.renderIndex(opts.Index) }),
+		"value": opts.Value.toPatchValue(),
+	}
+}
+
+// Append is a Patch that requests the addition of a value (Domain, Origin, or CacheRule) to a
+// Service at the end of its respective collection. Use an Insertion instead to insert the value
+// at a fixed index within the collection. Pass this to the Update function as part of its
+// Patch slice.
+type Append struct {
+	Value value
+}
+
+// ToCDNServiceUpdateMap converts an Append into a request body fragment suitable for the
+// Update call.
+func (a Append) ToCDNServiceUpdateMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "add",
+		"path":  a.Value.renderRootOr(func(p Path) string { return p.renderDash() }),
+		"value": a.Value.toPatchValue(),
+	}
+}
+
+// Replacement is a Patch that alters a specific service parameter (Domain, Origin, or CacheRule)
+// in-place by index. Pass it to the Update function as part of the Patch slice.
+type Replacement struct {
+	Value value
+	Index int64
+}
+
+// ToCDNServiceUpdateMap converts a Replacement into a request body fragment suitable for the
+// Update call.
+func (r Replacement) ToCDNServiceUpdateMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  r.Value.renderRootOr(func(p Path) string { return p.renderIndex(r.Index) }),
+		"value": r.Value.toPatchValue(),
+	}
+}
+
+// NameReplacement specifically updates the Service name. Pass it to the Update function as part
+// of the Patch slice.
+type NameReplacement struct {
+	NewName string
+}
+
+// ToCDNServiceUpdateMap converts a NameReplacement into a request body fragment suitable for the
+// Update call.
+func (r NameReplacement) ToCDNServiceUpdateMap() map[string]interface{} {
+	return map[string]interface{}{
+		"op":    "replace",
+		"path":  "/name",
+		"value": r.NewName,
+	}
+}
+
+// Removal is a Patch that requests the removal of a service parameter (Domain, Origin, or
+// CacheRule) by index. Pass it to the Update function as part of the Patch slice.
+type Removal struct {
+	Path  Path
+	Index int64
+	All   bool
+}
+
+// ToCDNServiceUpdateMap converts a Removal into a request body fragment suitable for the
+// Update call.
+func (opts Removal) ToCDNServiceUpdateMap() map[string]interface{} {
+	b := map[string]interface{}{"op": "remove"}
+	if opts.All {
+		b["path"] = opts.Path.renderRoot()
+	} else {
+		b["path"] = opts.Path.renderIndex(opts.Index)
+	}
+	return b
+}
+
+// UpdateOpts is a slice of Patches used to update a CDN service
+type UpdateOpts []Patch
+
+// Update accepts a slice of Patch operations (Insertion, Append, Replacement or Removal) and
+// updates an existing CDN service using the values provided. idOrURL can be either the service's
+// URL or its ID. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Update(c *gophercloud.ServiceClient, idOrURL string, opts UpdateOpts) (r UpdateResult) {
+	var url string
+	if strings.Contains(idOrURL, "/") {
+		url = idOrURL
+	} else {
+		url = updateURL(c, idOrURL)
+	}
+
+	b := make([]map[string]interface{}, len(opts))
+	for i, patch := range opts {
+		b[i] = patch.ToCDNServiceUpdateMap()
+	}
+
+	resp, err := c.Request("PATCH", url, &gophercloud.RequestOpts{
+		JSONBody: &b,
+		OkCodes:  []int{202},
+	})
+	r.Header = resp.Header
+	r.Err = err
+	return
+}
+
+// Delete accepts a service's ID or its URL and deletes the CDN service
+// associated with it. For example, both "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0" and
+// "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+// are valid options for idOrURL.
+func Delete(c *gophercloud.ServiceClient, idOrURL string) (r DeleteResult) {
+	var url string
+	if strings.Contains(idOrURL, "/") {
+		url = idOrURL
+	} else {
+		url = deleteURL(c, idOrURL)
+	}
+	_, r.Err = c.Delete(url, nil)
+	return
+}
diff --git a/openstack/cdn/v1/services/requests_test.go b/openstack/cdn/v1/services/requests_test.go
new file mode 100644
index 0000000..1f27b59
--- /dev/null
+++ b/openstack/cdn/v1/services/requests_test.go
@@ -0,0 +1,358 @@
+package services
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleListCDNServiceSuccessfully(t)
+
+	count := 0
+
+	err := List(fake.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractServices(page)
+		if err != nil {
+			t.Errorf("Failed to extract services: %v", err)
+			return false, err
+		}
+
+		expected := []Service{
+			Service{
+				ID:   "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+				Name: "mywebsite.com",
+				Domains: []Domain{
+					Domain{
+						Domain: "www.mywebsite.com",
+					},
+				},
+				Origins: []Origin{
+					Origin{
+						Origin: "mywebsite.com",
+						Port:   80,
+						SSL:    false,
+					},
+				},
+				Caching: []CacheRule{
+					CacheRule{
+						Name: "default",
+						TTL:  3600,
+					},
+					CacheRule{
+						Name: "home",
+						TTL:  17200,
+						Rules: []TTLRule{
+							TTLRule{
+								Name:       "index",
+								RequestURL: "/index.htm",
+							},
+						},
+					},
+					CacheRule{
+						Name: "images",
+						TTL:  12800,
+						Rules: []TTLRule{
+							TTLRule{
+								Name:       "images",
+								RequestURL: "*.png",
+							},
+						},
+					},
+				},
+				Restrictions: []Restriction{
+					Restriction{
+						Name: "website only",
+						Rules: []RestrictionRule{
+							RestrictionRule{
+								Name:     "mywebsite.com",
+								Referrer: "www.mywebsite.com",
+							},
+						},
+					},
+				},
+				FlavorID: "asia",
+				Status:   "deployed",
+				Errors:   []Error{},
+				Links: []gophercloud.Link{
+					gophercloud.Link{
+						Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+						Rel:  "self",
+					},
+					gophercloud.Link{
+						Href: "mywebsite.com.cdn123.poppycdn.net",
+						Rel:  "access_url",
+					},
+					gophercloud.Link{
+						Href: "https://www.poppycdn.io/v1.0/flavors/asia",
+						Rel:  "flavor",
+					},
+				},
+			},
+			Service{
+				ID:   "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+				Name: "myothersite.com",
+				Domains: []Domain{
+					Domain{
+						Domain: "www.myothersite.com",
+					},
+				},
+				Origins: []Origin{
+					Origin{
+						Origin: "44.33.22.11",
+						Port:   80,
+						SSL:    false,
+					},
+					Origin{
+						Origin: "77.66.55.44",
+						Port:   80,
+						SSL:    false,
+						Rules: []OriginRule{
+							OriginRule{
+								Name:       "videos",
+								RequestURL: "^/videos/*.m3u",
+							},
+						},
+					},
+				},
+				Caching: []CacheRule{
+					CacheRule{
+						Name: "default",
+						TTL:  3600,
+					},
+				},
+				Restrictions: []Restriction{},
+				FlavorID:     "europe",
+				Status:       "deployed",
+				Links: []gophercloud.Link{
+					gophercloud.Link{
+						Href: "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f1",
+						Rel:  "self",
+					},
+					gophercloud.Link{
+						Href: "myothersite.com.poppycdn.net",
+						Rel:  "access_url",
+					},
+					gophercloud.Link{
+						Href: "https://www.poppycdn.io/v1.0/flavors/europe",
+						Rel:  "flavor",
+					},
+				},
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleCreateCDNServiceSuccessfully(t)
+
+	createOpts := CreateOpts{
+		Name: "mywebsite.com",
+		Domains: []Domain{
+			Domain{
+				Domain: "www.mywebsite.com",
+			},
+			Domain{
+				Domain: "blog.mywebsite.com",
+			},
+		},
+		Origins: []Origin{
+			Origin{
+				Origin: "mywebsite.com",
+				Port:   80,
+				SSL:    false,
+			},
+		},
+		Restrictions: []Restriction{
+			Restriction{
+				Name: "website only",
+				Rules: []RestrictionRule{
+					RestrictionRule{
+						Name:     "mywebsite.com",
+						Referrer: "www.mywebsite.com",
+					},
+				},
+			},
+		},
+		Caching: []CacheRule{
+			CacheRule{
+				Name: "default",
+				TTL:  3600,
+			},
+		},
+		FlavorID: "cdn",
+	}
+
+	expected := "https://global.cdn.api.rackspacecloud.com/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+	actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleGetCDNServiceSuccessfully(t)
+
+	expected := &Service{
+		ID:   "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+		Name: "mywebsite.com",
+		Domains: []Domain{
+			Domain{
+				Domain:   "www.mywebsite.com",
+				Protocol: "http",
+			},
+		},
+		Origins: []Origin{
+			Origin{
+				Origin: "mywebsite.com",
+				Port:   80,
+				SSL:    false,
+			},
+		},
+		Caching: []CacheRule{
+			CacheRule{
+				Name: "default",
+				TTL:  3600,
+			},
+			CacheRule{
+				Name: "home",
+				TTL:  17200,
+				Rules: []TTLRule{
+					TTLRule{
+						Name:       "index",
+						RequestURL: "/index.htm",
+					},
+				},
+			},
+			CacheRule{
+				Name: "images",
+				TTL:  12800,
+				Rules: []TTLRule{
+					TTLRule{
+						Name:       "images",
+						RequestURL: "*.png",
+					},
+				},
+			},
+		},
+		Restrictions: []Restriction{
+			Restriction{
+				Name: "website only",
+				Rules: []RestrictionRule{
+					RestrictionRule{
+						Name:     "mywebsite.com",
+						Referrer: "www.mywebsite.com",
+					},
+				},
+			},
+		},
+		FlavorID: "cdn",
+		Status:   "deployed",
+		Errors:   []Error{},
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "blog.mywebsite.com.cdn1.raxcdn.com",
+				Rel:  "access_url",
+			},
+			gophercloud.Link{
+				Href: "https://global.cdn.api.rackspacecloud.com/v1.0/110011/flavors/cdn",
+				Rel:  "flavor",
+			},
+		},
+	}
+
+	actual, err := Get(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestSuccessfulUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleUpdateCDNServiceSuccessfully(t)
+
+	expected := "https://www.poppycdn.io/v1.0/services/96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0"
+	ops := UpdateOpts{
+		// Append a single Domain
+		Append{Value: Domain{Domain: "appended.mocksite4.com"}},
+		// Insert a single Domain
+		Insertion{
+			Index: 4,
+			Value: Domain{Domain: "inserted.mocksite4.com"},
+		},
+		// Bulk addition
+		Append{
+			Value: DomainList{
+				Domain{Domain: "bulkadded1.mocksite4.com"},
+				Domain{Domain: "bulkadded2.mocksite4.com"},
+			},
+		},
+		// Replace a single Origin
+		Replacement{
+			Index: 2,
+			Value: Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+		},
+		// Bulk replace Origins
+		Replacement{
+			Index: 0, // Ignored
+			Value: OriginList{
+				Origin{Origin: "44.33.22.11", Port: 80, SSL: false},
+				Origin{Origin: "55.44.33.22", Port: 443, SSL: true},
+			},
+		},
+		// Remove a single CacheRule
+		Removal{
+			Index: 8,
+			Path:  PathCaching,
+		},
+		// Bulk removal
+		Removal{
+			All:  true,
+			Path: PathCaching,
+		},
+		// Service name replacement
+		NameReplacement{
+			NewName: "differentServiceName",
+		},
+	}
+
+	actual, err := Update(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0", ops).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleDeleteCDNServiceSuccessfully(t)
+
+	err := Delete(fake.ServiceClient(), "96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/cdn/v1/services/results.go b/openstack/cdn/v1/services/results.go
new file mode 100644
index 0000000..f9a1caa
--- /dev/null
+++ b/openstack/cdn/v1/services/results.go
@@ -0,0 +1,304 @@
+package services
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Domain represents a domain used by users to access their website.
+type Domain struct {
+	// Specifies the domain used to access the assets on their website, for which
+	// a CNAME is given to the CDN provider.
+	Domain string `json:"domain" required:"true"`
+	// Specifies the protocol used to access the assets on this domain. Only "http"
+	// or "https" are currently allowed. The default is "http".
+	Protocol string `json:"protocol,omitempty"`
+}
+
+func (d Domain) toPatchValue() interface{} {
+	r := make(map[string]interface{})
+	r["domain"] = d.Domain
+	if d.Protocol != "" {
+		r["protocol"] = d.Protocol
+	}
+	return r
+}
+
+func (d Domain) appropriatePath() Path {
+	return PathDomains
+}
+
+func (d Domain) renderRootOr(render func(p Path) string) string {
+	return render(d.appropriatePath())
+}
+
+// DomainList provides a useful way to perform bulk operations in a single Patch.
+type DomainList []Domain
+
+func (list DomainList) toPatchValue() interface{} {
+	r := make([]interface{}, len(list))
+	for i, domain := range list {
+		r[i] = domain.toPatchValue()
+	}
+	return r
+}
+
+func (list DomainList) appropriatePath() Path {
+	return PathDomains
+}
+
+func (list DomainList) renderRootOr(_ func(p Path) string) string {
+	return list.appropriatePath().renderRoot()
+}
+
+// OriginRule represents a rule that defines when an origin should be accessed.
+type OriginRule struct {
+	// Specifies the name of this rule.
+	Name string `json:"name" required:"true"`
+	// Specifies the request URL this rule should match for this origin to be used. Regex is supported.
+	RequestURL string `json:"request_url" required:"true"`
+}
+
+// Origin specifies a list of origin domains or IP addresses where the original assets are stored.
+type Origin struct {
+	// Specifies the URL or IP address to pull origin content from.
+	Origin string `json:"origin" required:"true"`
+	// Specifies the port used to access the origin. The default is port 80.
+	Port int `json:"port,omitempty"`
+	// Specifies whether or not to use HTTPS to access the origin. The default
+	// is false.
+	SSL bool `json:"ssl"`
+	// Specifies a collection of rules that define the conditions when this origin
+	// should be accessed. If there is more than one origin, the rules parameter is required.
+	Rules []OriginRule `json:"rules,omitempty"`
+}
+
+func (o Origin) toPatchValue() interface{} {
+	r := make(map[string]interface{})
+	r["origin"] = o.Origin
+	r["port"] = o.Port
+	r["ssl"] = o.SSL
+	if len(o.Rules) > 0 {
+		r["rules"] = make([]map[string]interface{}, len(o.Rules))
+		for index, rule := range o.Rules {
+			submap := r["rules"].([]map[string]interface{})[index]
+			submap["name"] = rule.Name
+			submap["request_url"] = rule.RequestURL
+		}
+	}
+	return r
+}
+
+func (o Origin) appropriatePath() Path {
+	return PathOrigins
+}
+
+func (o Origin) renderRootOr(render func(p Path) string) string {
+	return render(o.appropriatePath())
+}
+
+// OriginList provides a useful way to perform bulk operations in a single Patch.
+type OriginList []Origin
+
+func (list OriginList) toPatchValue() interface{} {
+	r := make([]interface{}, len(list))
+	for i, origin := range list {
+		r[i] = origin.toPatchValue()
+	}
+	return r
+}
+
+func (list OriginList) appropriatePath() Path {
+	return PathOrigins
+}
+
+func (list OriginList) renderRootOr(_ func(p Path) string) string {
+	return list.appropriatePath().renderRoot()
+}
+
+// TTLRule specifies a rule that determines if a TTL should be applied to an asset.
+type TTLRule struct {
+	// Specifies the name of this rule.
+	Name string `json:"name" required:"true"`
+	// Specifies the request URL this rule should match for this TTL to be used. Regex is supported.
+	RequestURL string `json:"request_url" required:"true"`
+}
+
+// CacheRule specifies the TTL rules for the assets under this service.
+type CacheRule struct {
+	// Specifies the name of this caching rule. Note: 'default' is a reserved name used for the default TTL setting.
+	Name string `json:"name" required:"true"`
+	// Specifies the TTL to apply.
+	TTL int `json:"ttl,omitempty"`
+	// Specifies a collection of rules that determine if this TTL should be applied to an asset.
+	Rules []TTLRule `json:"rules,omitempty"`
+}
+
+func (c CacheRule) toPatchValue() interface{} {
+	r := make(map[string]interface{})
+	r["name"] = c.Name
+	r["ttl"] = c.TTL
+	r["rules"] = make([]map[string]interface{}, len(c.Rules))
+	for index, rule := range c.Rules {
+		submap := r["rules"].([]map[string]interface{})[index]
+		submap["name"] = rule.Name
+		submap["request_url"] = rule.RequestURL
+	}
+	return r
+}
+
+func (c CacheRule) appropriatePath() Path {
+	return PathCaching
+}
+
+func (c CacheRule) renderRootOr(render func(p Path) string) string {
+	return render(c.appropriatePath())
+}
+
+// CacheRuleList provides a useful way to perform bulk operations in a single Patch.
+type CacheRuleList []CacheRule
+
+func (list CacheRuleList) toPatchValue() interface{} {
+	r := make([]interface{}, len(list))
+	for i, rule := range list {
+		r[i] = rule.toPatchValue()
+	}
+	return r
+}
+
+func (list CacheRuleList) appropriatePath() Path {
+	return PathCaching
+}
+
+func (list CacheRuleList) renderRootOr(_ func(p Path) string) string {
+	return list.appropriatePath().renderRoot()
+}
+
+// RestrictionRule specifies a rule that determines if this restriction should be applied to an asset.
+type RestrictionRule struct {
+	// Specifies the name of this rule.
+	Name string `json:"name" required:"true"`
+	// Specifies the http host that requests must come from.
+	Referrer string `json:"referrer,omitempty"`
+}
+
+// Restriction specifies a restriction that defines who can access assets (content from the CDN cache).
+type Restriction struct {
+	// Specifies the name of this restriction.
+	Name string `json:"name" required:"true"`
+	// Specifies a collection of rules that determine if this TTL should be applied to an asset.
+	Rules []RestrictionRule `json:"rules,omitempty"`
+}
+
+// Error specifies an error that occurred during the previous service action.
+type Error struct {
+	// Specifies an error message detailing why there is an error.
+	Message string `json:"message"`
+}
+
+// Service represents a CDN service resource.
+type Service struct {
+	// Specifies the service ID that represents distributed content. The value is
+	// a UUID, such as 96737ae3-cfc1-4c72-be88-5d0e7cc9a3f0, that is generated by the server.
+	ID string `json:"id"`
+	// Specifies the name of the service.
+	Name string `json:"name"`
+	// Specifies a list of domains used by users to access their website.
+	Domains []Domain `json:"domains"`
+	// Specifies a list of origin domains or IP addresses where the original assets are stored.
+	Origins []Origin `json:"origins"`
+	// Specifies the TTL rules for the assets under this service. Supports wildcards for fine grained control.
+	Caching []CacheRule `json:"caching"`
+	// Specifies the restrictions that define who can access assets (content from the CDN cache).
+	Restrictions []Restriction `json:"restrictions"`
+	// Specifies the CDN provider flavor ID to use. For a list of flavors, see the operation to list the available flavors.
+	FlavorID string `json:"flavor_id"`
+	// Specifies the current status of the service.
+	Status string `json:"status"`
+	// Specifies the list of errors that occurred during the previous service action.
+	Errors []Error `json:"errors"`
+	// Specifies the self-navigating JSON document paths.
+	Links []gophercloud.Link `json:"links"`
+}
+
+// ServicePage is the page returned by a pager when traversing over a
+// collection of CDN services.
+type ServicePage struct {
+	pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a ListResult contains no services.
+func (r ServicePage) IsEmpty() (bool, error) {
+	services, err := ExtractServices(r)
+	return len(services) == 0, err
+}
+
+// LastMarker returns the last service in a ListResult.
+func (r ServicePage) LastMarker() (string, error) {
+	services, err := ExtractServices(r)
+	if err != nil {
+		return "", err
+	}
+	if len(services) == 0 {
+		return "", nil
+	}
+	return (services[len(services)-1]).ID, nil
+}
+
+// ExtractServices is a function that takes a ListResult and returns the services' information.
+func ExtractServices(r pagination.Page) ([]Service, error) {
+	var s struct {
+		Services []Service `json:"services"`
+	}
+	err := (r.(ServicePage)).ExtractInto(&s)
+	return s.Services, err
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that extracts the location of a newly created service.
+func (r CreateResult) Extract() (string, error) {
+	if r.Err != nil {
+		return "", r.Err
+	}
+	if l, ok := r.Header["Location"]; ok && len(l) > 0 {
+		return l[0], nil
+	}
+	return "", nil
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that extracts a service from a GetResult.
+func (r GetResult) Extract() (*Service, error) {
+	var s Service
+	err := r.ExtractInto(&s)
+	return &s, err
+}
+
+// UpdateResult represents the result of a Update operation.
+type UpdateResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that extracts the location of an updated service.
+func (r UpdateResult) Extract() (string, error) {
+	if r.Err != nil {
+		return "", r.Err
+	}
+	if l, ok := r.Header["Location"]; ok && len(l) > 0 {
+		return l[0], nil
+	}
+	return "", nil
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/cdn/v1/services/urls.go b/openstack/cdn/v1/services/urls.go
new file mode 100644
index 0000000..5bb3ca9
--- /dev/null
+++ b/openstack/cdn/v1/services/urls.go
@@ -0,0 +1,23 @@
+package services
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("services")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return listURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("services", id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return getURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return getURL(c, id)
+}
diff --git a/openstack/client.go b/openstack/client.go
new file mode 100644
index 0000000..2fa4750
--- /dev/null
+++ b/openstack/client.go
@@ -0,0 +1,321 @@
+package openstack
+
+import (
+	"fmt"
+	"net/url"
+
+	"github.com/gophercloud/gophercloud"
+	tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+	tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+	"github.com/gophercloud/gophercloud/openstack/utils"
+)
+
+const (
+	v20 = "v2.0"
+	v30 = "v3.0"
+)
+
+// NewClient prepares an unauthenticated ProviderClient instance.
+// Most users will probably prefer using the AuthenticatedClient function instead.
+// This is useful if you wish to explicitly control the version of the identity service that's used for authentication explicitly,
+// for example.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+	u, err := url.Parse(endpoint)
+	if err != nil {
+		return nil, err
+	}
+	hadPath := u.Path != ""
+	u.Path, u.RawQuery, u.Fragment = "", "", ""
+	base := u.String()
+
+	endpoint = gophercloud.NormalizeURL(endpoint)
+	base = gophercloud.NormalizeURL(base)
+
+	if hadPath {
+		return &gophercloud.ProviderClient{
+			IdentityBase:     base,
+			IdentityEndpoint: endpoint,
+		}, nil
+	}
+
+	return &gophercloud.ProviderClient{
+		IdentityBase:     base,
+		IdentityEndpoint: "",
+	}, nil
+}
+
+// AuthenticatedClient logs in to an OpenStack cloud found at the identity endpoint specified by options, acquires a token, and
+// returns a Client instance that's ready to operate.
+// It first queries the root identity endpoint to determine which versions of the identity service are supported, then chooses
+// the most recent identity service available to proceed.
+func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
+	client, err := NewClient(options.IdentityEndpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	err = Authenticate(client, options)
+	if err != nil {
+		return nil, err
+	}
+	return client, nil
+}
+
+// Authenticate or re-authenticate against the most recent identity service supported at the provided endpoint.
+func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	versions := []*utils.Version{
+		{ID: v20, Priority: 20, Suffix: "/v2.0/"},
+		{ID: v30, Priority: 30, Suffix: "/v3/"},
+	}
+
+	chosen, endpoint, err := utils.ChooseVersion(client, versions)
+	if err != nil {
+		return err
+	}
+
+	switch chosen.ID {
+	case v20:
+		return v2auth(client, endpoint, options, gophercloud.EndpointOpts{})
+	case v30:
+		return v3auth(client, endpoint, options, gophercloud.EndpointOpts{})
+	default:
+		// The switch statement must be out of date from the versions list.
+		return fmt.Errorf("Unrecognized identity version: %s", chosen.ID)
+	}
+}
+
+// AuthenticateV2 explicitly authenticates against the identity v2 endpoint.
+func AuthenticateV2(client *gophercloud.ProviderClient, options gophercloud.AuthOptions, eo gophercloud.EndpointOpts) error {
+	return v2auth(client, "", options, eo)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions, eo gophercloud.EndpointOpts) error {
+	v2Client, err := NewIdentityV2(client, eo)
+	if err != nil {
+		return err
+	}
+
+	if endpoint != "" {
+		v2Client.Endpoint = endpoint
+	}
+
+	v2Opts := tokens2.AuthOptions{
+		IdentityEndpoint: options.IdentityEndpoint,
+		Username:         options.Username,
+		Password:         options.Password,
+		TenantID:         options.TenantID,
+		TenantName:       options.TenantName,
+		AllowReauth:      options.AllowReauth,
+		TokenID:          options.TokenID,
+	}
+
+	result := tokens2.Create(v2Client, v2Opts)
+
+	token, err := result.ExtractToken()
+	if err != nil {
+		return err
+	}
+
+	catalog, err := result.ExtractServiceCatalog()
+	if err != nil {
+		return err
+	}
+
+	if options.AllowReauth {
+		client.ReauthFunc = func() error {
+			client.TokenID = ""
+			return v2auth(client, endpoint, options, eo)
+		}
+	}
+	client.TokenID = token.ID
+	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+		return V2EndpointURL(catalog, opts)
+	}
+
+	return nil
+}
+
+// AuthenticateV3 explicitly authenticates against the identity v3 service.
+func AuthenticateV3(client *gophercloud.ProviderClient, options gophercloud.AuthOptions, eo gophercloud.EndpointOpts) error {
+	return v3auth(client, "", options, eo)
+}
+
+func v3auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions, eo gophercloud.EndpointOpts) error {
+	// Override the generated service endpoint with the one returned by the version endpoint.
+	v3Client, err := NewIdentityV3(client, eo)
+	if err != nil {
+		return err
+	}
+
+	if endpoint != "" {
+		v3Client.Endpoint = endpoint
+	}
+
+	var scope *tokens3.Scope
+	if options.TenantID != "" {
+		scope = &tokens3.Scope{
+			ProjectID: options.TenantID,
+		}
+		options.TenantID = ""
+		options.TenantName = ""
+	} else {
+		if options.TenantName != "" {
+			scope = &tokens3.Scope{
+				ProjectName: options.TenantName,
+				DomainID:    options.DomainID,
+				DomainName:  options.DomainName,
+			}
+			options.TenantName = ""
+		}
+	}
+
+	v3Opts := tokens3.AuthOptions{
+		IdentityEndpoint: options.IdentityEndpoint,
+		Username:         options.Username,
+		UserID:           options.UserID,
+		Password:         options.Password,
+		DomainID:         options.DomainID,
+		DomainName:       options.DomainName,
+		TenantID:         options.TenantID,
+		TenantName:       options.TenantName,
+		AllowReauth:      options.AllowReauth,
+		TokenID:          options.TokenID,
+	}
+
+	result := tokens3.Create(v3Client, v3Opts, scope)
+
+	token, err := result.ExtractToken()
+	if err != nil {
+		return err
+	}
+
+	catalog, err := result.ExtractServiceCatalog()
+	if err != nil {
+		return err
+	}
+
+	client.TokenID = token.ID
+
+	if options.AllowReauth {
+		client.ReauthFunc = func() error {
+			client.TokenID = ""
+			return v3auth(client, endpoint, options, eo)
+		}
+	}
+	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+		return V3EndpointURL(catalog, opts)
+	}
+
+	return nil
+}
+
+// NewIdentityV2 creates a ServiceClient that may be used to interact with the v2 identity service.
+func NewIdentityV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	v2Endpoint := client.IdentityBase + "v2.0/"
+	/*
+		eo.ApplyDefaults("identity")
+		url, err := client.EndpointLocator(eo)
+		if err != nil {
+			return nil, err
+		}
+	*/
+
+	return &gophercloud.ServiceClient{
+		ProviderClient: client,
+		Endpoint:       v2Endpoint,
+		//Endpoint: url,
+	}, nil
+}
+
+// NewIdentityV3 creates a ServiceClient that may be used to access the v3 identity service.
+func NewIdentityV3(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	v3Endpoint := client.IdentityBase + "v3/"
+	/*
+		eo.ApplyDefaults("identity")
+		url, err := client.EndpointLocator(eo)
+		if err != nil {
+			return nil, err
+		}
+	*/
+
+	return &gophercloud.ServiceClient{
+		ProviderClient: client,
+		Endpoint:       v3Endpoint,
+		//Endpoint: url,
+	}, nil
+}
+
+// NewObjectStorageV1 creates a ServiceClient that may be used with the v1 object storage package.
+func NewObjectStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("object-store")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewComputeV2 creates a ServiceClient that may be used with the v2 compute package.
+func NewComputeV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("compute")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewNetworkV2 creates a ServiceClient that may be used with the v2 network package.
+func NewNetworkV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("network")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{
+		ProviderClient: client,
+		Endpoint:       url,
+		ResourceBase:   url + "v2.0/",
+	}, nil
+}
+
+// NewBlockStorageV1 creates a ServiceClient that may be used to access the v1 block storage service.
+func NewBlockStorageV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("volume")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewCDNV1 creates a ServiceClient that may be used to access the OpenStack v1
+// CDN service.
+func NewCDNV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("cdn")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewOrchestrationV1 creates a ServiceClient that may be used to access the v1 orchestration service.
+func NewOrchestrationV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("orchestration")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
+
+// NewDBV1 creates a ServiceClient that may be used to access the v1 DB service.
+func NewDBV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {
+	eo.ApplyDefaults("database")
+	url, err := client.EndpointLocator(eo)
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil
+}
diff --git a/openstack/client_test.go b/openstack/client_test.go
new file mode 100644
index 0000000..8698756
--- /dev/null
+++ b/openstack/client_test.go
@@ -0,0 +1,161 @@
+package openstack
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV3(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	const ID = "0123456789"
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "stable",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, th.Endpoint()+"v3/", th.Endpoint()+"v2.0/")
+	})
+
+	th.Mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("X-Subject-Token", ID)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{ "token": { "expires_at": "2013-02-02T18:30:59.000000Z" } }`)
+	})
+
+	options := gophercloud.AuthOptions{
+		UserID:           "me",
+		Password:         "secret",
+		IdentityEndpoint: th.Endpoint(),
+	}
+	client, err := AuthenticatedClient(options)
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, ID, client.TokenID)
+}
+
+func TestAuthenticatedClientV2(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "experimental",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, th.Endpoint()+"v3/", th.Endpoint()+"v2.0/")
+	})
+
+	th.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"access": {
+					"token": {
+						"id": "01234567890",
+						"expires": "2014-10-01T10:00:00.000000Z"
+					},
+					"serviceCatalog": [
+						{
+							"name": "Cloud Servers",
+							"type": "compute",
+							"endpoints": [
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://compute.north.host.com/v1/t1000",
+									"internalURL": "https://compute.north.internal/v1/t1000",
+									"region": "North",
+									"versionId": "1",
+									"versionInfo": "https://compute.north.host.com/v1/",
+									"versionList": "https://compute.north.host.com/"
+								},
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://compute.north.host.com/v1.1/t1000",
+									"internalURL": "https://compute.north.internal/v1.1/t1000",
+									"region": "North",
+									"versionId": "1.1",
+									"versionInfo": "https://compute.north.host.com/v1.1/",
+									"versionList": "https://compute.north.host.com/"
+								}
+							],
+							"endpoints_links": []
+						},
+						{
+							"name": "Cloud Files",
+							"type": "object-store",
+							"endpoints": [
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://storage.north.host.com/v1/t1000",
+									"internalURL": "https://storage.north.internal/v1/t1000",
+									"region": "North",
+									"versionId": "1",
+									"versionInfo": "https://storage.north.host.com/v1/",
+									"versionList": "https://storage.north.host.com/"
+								},
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://storage.south.host.com/v1/t1000",
+									"internalURL": "https://storage.south.internal/v1/t1000",
+									"region": "South",
+									"versionId": "1",
+									"versionInfo": "https://storage.south.host.com/v1/",
+									"versionList": "https://storage.south.host.com/"
+								}
+							]
+						}
+					]
+				}
+			}
+		`)
+	})
+
+	options := gophercloud.AuthOptions{
+		Username:         "me",
+		Password:         "secret",
+		IdentityEndpoint: th.Endpoint(),
+	}
+	client, err := AuthenticatedClient(options)
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, "01234567890", client.TokenID)
+}
diff --git a/openstack/common/README.md b/openstack/common/README.md
new file mode 100644
index 0000000..7b55795
--- /dev/null
+++ b/openstack/common/README.md
@@ -0,0 +1,3 @@
+# Common Resources
+
+This directory is for resources that are shared by multiple services.
diff --git a/openstack/common/extensions/doc.go b/openstack/common/extensions/doc.go
new file mode 100644
index 0000000..4a168f4
--- /dev/null
+++ b/openstack/common/extensions/doc.go
@@ -0,0 +1,15 @@
+// Package extensions provides information and interaction with the different extensions available
+// for an OpenStack service.
+//
+// The purpose of OpenStack API extensions is to:
+//
+// - Introduce new features in the API without requiring a version change.
+// - Introduce vendor-specific niche functionality.
+// - Act as a proving ground for experimental functionalities that might be included in a future
+//   version of the API.
+//
+// Extensions usually have tags that prevent conflicts with other extensions that define attributes
+// or resources with the same names, and with core resources and attributes.
+// Because an extension might not be supported by all plug-ins, its availability varies with deployments
+// and the specific plug-in.
+package extensions
diff --git a/openstack/common/extensions/errors.go b/openstack/common/extensions/errors.go
new file mode 100755
index 0000000..aeec0fa
--- /dev/null
+++ b/openstack/common/extensions/errors.go
@@ -0,0 +1 @@
+package extensions
diff --git a/openstack/common/extensions/fixtures.go b/openstack/common/extensions/fixtures.go
new file mode 100644
index 0000000..00d3db4
--- /dev/null
+++ b/openstack/common/extensions/fixtures.go
@@ -0,0 +1,91 @@
+// +build fixtures
+
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Extension results.
+const ListOutput = `
+{
+	"extensions": [
+		{
+			"updated": "2013-01-20T00:00:00-00:00",
+			"name": "Neutron Service Type Management",
+			"links": [],
+			"namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+			"alias": "service-type",
+			"description": "API for retrieving service providers for Neutron advanced services"
+		}
+	]
+}`
+
+// GetOutput provides a single Extension result.
+const GetOutput = `
+{
+	"extension": {
+		"updated": "2013-02-03T10:00:00-00:00",
+		"name": "agent",
+		"links": [],
+		"namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+		"alias": "agent",
+		"description": "The agent management extension."
+	}
+}
+`
+
+// ListedExtension is the Extension that should be parsed from ListOutput.
+var ListedExtension = Extension{
+	Updated:     "2013-01-20T00:00:00-00:00",
+	Name:        "Neutron Service Type Management",
+	Links:       []interface{}{},
+	Namespace:   "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+	Alias:       "service-type",
+	Description: "API for retrieving service providers for Neutron advanced services",
+}
+
+// ExpectedExtensions is a slice containing the Extension that should be parsed from ListOutput.
+var ExpectedExtensions = []Extension{ListedExtension}
+
+// SingleExtension is the Extension that should be parsed from GetOutput.
+var SingleExtension = &Extension{
+	Updated:     "2013-02-03T10:00:00-00:00",
+	Name:        "agent",
+	Links:       []interface{}{},
+	Namespace:   "http://docs.openstack.org/ext/agent/api/v2.0",
+	Alias:       "agent",
+	Description: "The agent management extension.",
+}
+
+// HandleListExtensionsSuccessfully creates an HTTP handler at `/extensions` on the test handler
+// mux that response with a list containing a single tenant.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetExtensionSuccessfully creates an HTTP handler at `/extensions/agent` that responds with
+// a JSON payload corresponding to SingleExtension.
+func HandleGetExtensionSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, GetOutput)
+	})
+}
diff --git a/openstack/common/extensions/requests.go b/openstack/common/extensions/requests.go
new file mode 100755
index 0000000..46b7d60
--- /dev/null
+++ b/openstack/common/extensions/requests.go
@@ -0,0 +1,20 @@
+package extensions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) (r GetResult) {
+	_, r.Err = c.Get(ExtensionURL(c, alias), &r.Body, nil)
+	return
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, ListExtensionURL(c), func(r pagination.PageResult) pagination.Page {
+		return ExtensionPage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/common/extensions/requests_test.go b/openstack/common/extensions/requests_test.go
new file mode 100644
index 0000000..5caf407
--- /dev/null
+++ b/openstack/common/extensions/requests_test.go
@@ -0,0 +1,38 @@
+package extensions
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListExtensionsSuccessfully(t)
+
+	count := 0
+
+	List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+		th.AssertDeepEquals(t, ExpectedExtensions, actual)
+
+		return true, nil
+	})
+
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetExtensionSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, SingleExtension, actual)
+}
diff --git a/openstack/common/extensions/results.go b/openstack/common/extensions/results.go
new file mode 100755
index 0000000..d5f8650
--- /dev/null
+++ b/openstack/common/extensions/results.go
@@ -0,0 +1,53 @@
+package extensions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// GetResult temporarily stores the result of a Get call.
+// Use its Extract() method to interpret it as an Extension.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets a GetResult as an Extension.
+func (r GetResult) Extract() (*Extension, error) {
+	var s struct {
+		Extension *Extension `json:"extension"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Extension, err
+}
+
+// Extension is a struct that represents an OpenStack extension.
+type Extension struct {
+	Updated     string        `json:"updated"`
+	Name        string        `json:"name"`
+	Links       []interface{} `json:"links"`
+	Namespace   string        `json:"namespace"`
+	Alias       string        `json:"alias"`
+	Description string        `json:"description"`
+}
+
+// ExtensionPage is the page returned by a pager when traversing over a collection of extensions.
+type ExtensionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an ExtensionPage struct is empty.
+func (r ExtensionPage) IsEmpty() (bool, error) {
+	is, err := ExtractExtensions(r)
+	return len(is) == 0, err
+}
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of Extension structs.
+// In other words, a generic collection is mapped into a relevant slice.
+func ExtractExtensions(r pagination.Page) ([]Extension, error) {
+	var s struct {
+		Extensions []Extension `json:"extensions"`
+	}
+	err := (r.(ExtensionPage)).ExtractInto(&s)
+	return s.Extensions, err
+}
diff --git a/openstack/common/extensions/urls.go b/openstack/common/extensions/urls.go
new file mode 100644
index 0000000..eaf38b2
--- /dev/null
+++ b/openstack/common/extensions/urls.go
@@ -0,0 +1,13 @@
+package extensions
+
+import "github.com/gophercloud/gophercloud"
+
+// ExtensionURL generates the URL for an extension resource by name.
+func ExtensionURL(c *gophercloud.ServiceClient, name string) string {
+	return c.ServiceURL("extensions", name)
+}
+
+// ListExtensionURL generates the URL for the extensions resource collection.
+func ListExtensionURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("extensions")
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests.go b/openstack/compute/v2/extensions/bootfromvolume/requests.go
new file mode 100644
index 0000000..28fef94
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/requests.go
@@ -0,0 +1,92 @@
+package bootfromvolume
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+// SourceType represents the type of medium being used to create the volume.
+type SourceType string
+
+const (
+	// Volume SourceType
+	Volume SourceType = "volume"
+	// Snapshot SourceType
+	Snapshot SourceType = "snapshot"
+	// Image SourceType
+	Image SourceType = "image"
+	// Blank SourceType
+	Blank SourceType = "blank"
+)
+
+// BlockDevice is a structure with options for booting a server instance
+// from a volume. The volume may be created from an image, snapshot, or another
+// volume.
+type BlockDevice struct {
+	// SourceType must be one of: "volume", "snapshot", "image".
+	SourceType SourceType `json:"source_type" required:"true"`
+	// UUID is the unique identifier for the volume, snapshot, or image (see above)
+	UUID string `json:"uuid,omitempty"`
+	// BootIndex is the boot index. It defaults to 0.
+	BootIndex int `json:"boot_index"`
+	// DeleteOnTermination specifies whether or not to delete the attached volume
+	// when the server is deleted. Defaults to `false`.
+	DeleteOnTermination bool `json:"delete_on_termination"`
+	// DestinationType is the type that gets created. Possible values are "volume"
+	// and "local".
+	DestinationType string `json:"destination_type,omitempty"`
+	// GuestFormat specifies the format of the block device.
+	GuestFormat string `json:"guest_format,omitempty"`
+	// VolumeSize is the size of the volume to create (in gigabytes).
+	VolumeSize int `json:"volume_size"`
+}
+
+// CreateOptsExt is a structure that extends the server `CreateOpts` structure
+// by allowing for a block device mapping.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+	BlockDevice []BlockDevice `json:"block_device_mapping_v2,omitempty"`
+}
+
+// ToServerCreateMap adds the block device mapping option to the base server
+// creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+	base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(opts.BlockDevice) == 0 {
+		err := gophercloud.ErrMissingInput{}
+		err.Argument = "bootfromvolume.CreateOptsExt.BlockDevice"
+		return nil, err
+	}
+
+	serverMap := base["server"].(map[string]interface{})
+
+	blockDevice := make([]map[string]interface{}, len(opts.BlockDevice))
+
+	for i, bd := range opts.BlockDevice {
+		b, err := gophercloud.BuildRequestBody(bd, "")
+		if err != nil {
+			return nil, err
+		}
+		blockDevice[i] = b
+	}
+	serverMap["block_device_mapping_v2"] = blockDevice
+
+	return base, nil
+}
+
+// Create requests the creation of a server from the given block device mapping.
+func Create(client *gophercloud.ServiceClient, opts servers.CreateOptsBuilder) (r servers.CreateResult) {
+	b, err := opts.ToServerCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 202},
+	})
+	return
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests_test.go b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go
new file mode 100644
index 0000000..d85070d
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go
@@ -0,0 +1,128 @@
+package bootfromvolume
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		BlockDevice: []BlockDevice{
+			BlockDevice{
+				UUID:                "123456",
+				SourceType:          Image,
+				DestinationType:     "volume",
+				VolumeSize:          10,
+				DeleteOnTermination: false,
+			},
+		},
+	}
+
+	expected := `
+    {
+      "server": {
+        "name": "createdserver",
+        "imageRef": "asdfasdfasdf",
+        "flavorRef": "performance1-1",
+        "block_device_mapping_v2":[
+          {
+            "uuid":"123456",
+            "source_type":"image",
+            "destination_type":"volume",
+            "boot_index": 0,
+            "delete_on_termination": false,
+            "volume_size": 10
+          }
+        ]
+      }
+    }
+  `
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestCreateMultiEphemeralOpts(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		BlockDevice: []BlockDevice{
+			BlockDevice{
+				BootIndex:           0,
+				DeleteOnTermination: true,
+				DestinationType:     "local",
+				SourceType:          Image,
+				UUID:                "123456",
+			},
+			BlockDevice{
+				BootIndex:           -1,
+				DeleteOnTermination: true,
+				DestinationType:     "local",
+				GuestFormat:         "ext4",
+				SourceType:          Blank,
+				VolumeSize:          1,
+			},
+			BlockDevice{
+				BootIndex:           -1,
+				DeleteOnTermination: true,
+				DestinationType:     "local",
+				GuestFormat:         "ext4",
+				SourceType:          Blank,
+				VolumeSize:          1,
+			},
+		},
+	}
+
+	expected := `
+    {
+      "server": {
+        "name": "createdserver",
+        "imageRef": "asdfasdfasdf",
+        "flavorRef": "performance1-1",
+        "block_device_mapping_v2":[
+          {
+            "boot_index": 0,
+            "delete_on_termination": true,
+            "destination_type":"local",
+            "source_type":"image",
+            "uuid":"123456",
+            "volume_size": 0
+          },
+          {
+            "boot_index": -1,
+            "delete_on_termination": true,
+            "destination_type":"local",
+            "guest_format":"ext4",
+            "source_type":"blank",
+            "volume_size": 1
+          },
+          {
+            "boot_index": -1,
+            "delete_on_termination": true,
+            "destination_type":"local",
+            "guest_format":"ext4",
+            "source_type":"blank",
+            "volume_size": 1
+          }
+        ]
+      }
+    }
+  `
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/results.go b/openstack/compute/v2/extensions/bootfromvolume/results.go
new file mode 100644
index 0000000..3211fb1
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/results.go
@@ -0,0 +1,10 @@
+package bootfromvolume
+
+import (
+	os "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+// CreateResult temporarily contains the response from a Create call.
+type CreateResult struct {
+	os.CreateResult
+}
diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls.go b/openstack/compute/v2/extensions/bootfromvolume/urls.go
new file mode 100644
index 0000000..dc007ea
--- /dev/null
+++ b/openstack/compute/v2/extensions/bootfromvolume/urls.go
@@ -0,0 +1,7 @@
+package bootfromvolume
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("os-volumes_boot")
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/doc.go b/openstack/compute/v2/extensions/defsecrules/doc.go
new file mode 100644
index 0000000..2571a1a
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/doc.go
@@ -0,0 +1 @@
+package defsecrules
diff --git a/openstack/compute/v2/extensions/defsecrules/fixtures.go b/openstack/compute/v2/extensions/defsecrules/fixtures.go
new file mode 100644
index 0000000..d35af0b
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/fixtures.go
@@ -0,0 +1,108 @@
+package defsecrules
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const rootPath = "/os-security-group-default-rules"
+
+func mockListRulesResponse(t *testing.T) {
+	th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group_default_rules": [
+    {
+      "from_port": 80,
+      "id": "{ruleID}",
+      "ip_protocol": "TCP",
+      "ip_range": {
+        "cidr": "10.10.10.0/24"
+      },
+      "to_port": 80
+    }
+  ]
+}
+      `)
+	})
+}
+
+func mockCreateRuleResponse(t *testing.T) {
+	th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "security_group_default_rule": {
+    "ip_protocol": "TCP",
+    "from_port": 80,
+    "to_port": 80,
+    "cidr": "10.10.12.0/24"
+  }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group_default_rule": {
+    "from_port": 80,
+    "id": "{ruleID}",
+    "ip_protocol": "TCP",
+    "ip_range": {
+      "cidr": "10.10.12.0/24"
+    },
+    "to_port": 80
+  }
+}
+`)
+	})
+}
+
+func mockGetRuleResponse(t *testing.T, ruleID string) {
+	url := rootPath + "/" + ruleID
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group_default_rule": {
+    "id": "{ruleID}",
+    "from_port": 80,
+    "to_port": 80,
+    "ip_protocol": "TCP",
+    "ip_range": {
+      "cidr": "10.10.12.0/24"
+    }
+  }
+}
+			`)
+	})
+}
+
+func mockDeleteRuleResponse(t *testing.T, ruleID string) {
+	url := rootPath + "/" + ruleID
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/requests.go b/openstack/compute/v2/extensions/defsecrules/requests.go
new file mode 100644
index 0000000..6f24bb3
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests.go
@@ -0,0 +1,62 @@
+package defsecrules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will return a collection of default rules.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+		return DefaultRulePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOpts represents the configuration for adding a new default rule.
+type CreateOpts struct {
+	// The lower bound of the port range that will be opened.
+	FromPort int `json:"from_port" required:"true"`
+	// The upper bound of the port range that will be opened.
+	ToPort int `json:"to_port" required:"true"`
+	// The protocol type that will be allowed, e.g. TCP.
+	IPProtocol string `json:"ip_protocol" required:"true"`
+	// ONLY required if FromGroupID is blank. This represents the IP range that
+	// will be the source of network traffic to your security group. Use
+	// 0.0.0.0/0 to allow all IP addresses.
+	CIDR string `json:"cidr,omitempty"`
+}
+
+// CreateOptsBuilder builds the create rule options into a serializable format.
+type CreateOptsBuilder interface {
+	ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// ToRuleCreateMap builds the create rule options into a serializable format.
+func (opts CreateOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group_default_rule")
+}
+
+// Create is the operation responsible for creating a new default rule.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToRuleCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get will return details for a particular default rule.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete will permanently delete a default rule from the project.
+func Delete(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Delete(resourceURL(client, id), nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/requests_test.go b/openstack/compute/v2/extensions/defsecrules/requests_test.go
new file mode 100644
index 0000000..0e2a010
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/requests_test.go
@@ -0,0 +1,100 @@
+package defsecrules
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const ruleID = "{ruleID}"
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockListRulesResponse(t)
+
+	count := 0
+
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractDefaultRules(page)
+		th.AssertNoErr(t, err)
+
+		expected := []DefaultRule{
+			DefaultRule{
+				FromPort:   80,
+				ID:         ruleID,
+				IPProtocol: "TCP",
+				IPRange:    secgroups.IPRange{CIDR: "10.10.10.0/24"},
+				ToPort:     80,
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockCreateRuleResponse(t)
+
+	opts := CreateOpts{
+		IPProtocol: "TCP",
+		FromPort:   80,
+		ToPort:     80,
+		CIDR:       "10.10.12.0/24",
+	}
+
+	group, err := Create(client.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &DefaultRule{
+		ID:         ruleID,
+		FromPort:   80,
+		ToPort:     80,
+		IPProtocol: "TCP",
+		IPRange:    secgroups.IPRange{CIDR: "10.10.12.0/24"},
+	}
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockGetRuleResponse(t, ruleID)
+
+	group, err := Get(client.ServiceClient(), ruleID).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &DefaultRule{
+		ID:         ruleID,
+		FromPort:   80,
+		ToPort:     80,
+		IPProtocol: "TCP",
+		IPRange:    secgroups.IPRange{CIDR: "10.10.12.0/24"},
+	}
+
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockDeleteRuleResponse(t, ruleID)
+
+	err := Delete(client.ServiceClient(), ruleID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/results.go b/openstack/compute/v2/extensions/defsecrules/results.go
new file mode 100644
index 0000000..61b918d
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/results.go
@@ -0,0 +1,55 @@
+package defsecrules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/secgroups"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// DefaultRule represents a default rule - which is identical to a
+// normal security rule.
+type DefaultRule secgroups.Rule
+
+// DefaultRulePage is a single page of a DefaultRule collection.
+type DefaultRulePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of default rules contains any results.
+func (page DefaultRulePage) IsEmpty() (bool, error) {
+	users, err := ExtractDefaultRules(page)
+	return len(users) == 0, err
+}
+
+// ExtractDefaultRules returns a slice of DefaultRules contained in a single
+// page of results.
+func ExtractDefaultRules(r pagination.Page) ([]DefaultRule, error) {
+	var s struct {
+		DefaultRules []DefaultRule `json:"security_group_default_rules"`
+	}
+	err := (r.(DefaultRulePage)).ExtractInto(&s)
+	return s.DefaultRules, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// Extract will extract a DefaultRule struct from most responses.
+func (r commonResult) Extract() (*DefaultRule, error) {
+	var s struct {
+		DefaultRule DefaultRule `json:"security_group_default_rule"`
+	}
+	err := r.ExtractInto(&s)
+	return &s.DefaultRule, err
+}
diff --git a/openstack/compute/v2/extensions/defsecrules/urls.go b/openstack/compute/v2/extensions/defsecrules/urls.go
new file mode 100644
index 0000000..e5fbf82
--- /dev/null
+++ b/openstack/compute/v2/extensions/defsecrules/urls.go
@@ -0,0 +1,13 @@
+package defsecrules
+
+import "github.com/gophercloud/gophercloud"
+
+const rulepath = "os-security-group-default-rules"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rulepath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rulepath)
+}
diff --git a/openstack/compute/v2/extensions/delegate.go b/openstack/compute/v2/extensions/delegate.go
new file mode 100644
index 0000000..00e7c3b
--- /dev/null
+++ b/openstack/compute/v2/extensions/delegate.go
@@ -0,0 +1,23 @@
+package extensions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ExtractExtensions interprets a Page as a slice of Extensions.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+	return common.ExtractExtensions(page)
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+	return common.Get(c, alias)
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return common.List(c)
+}
diff --git a/openstack/compute/v2/extensions/delegate_test.go b/openstack/compute/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..4c602ce
--- /dev/null
+++ b/openstack/compute/v2/extensions/delegate_test.go
@@ -0,0 +1,96 @@
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, `
+{
+		"extensions": [
+				{
+						"updated": "2013-01-20T00:00:00-00:00",
+						"name": "Neutron Service Type Management",
+						"links": [],
+						"namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+						"alias": "service-type",
+						"description": "API for retrieving service providers for Neutron advanced services"
+				}
+		]
+}
+			`)
+	})
+
+	count := 0
+	List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+
+		expected := []common.Extension{
+			common.Extension{
+				Updated:     "2013-01-20T00:00:00-00:00",
+				Name:        "Neutron Service Type Management",
+				Links:       []interface{}{},
+				Namespace:   "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+				Alias:       "service-type",
+				Description: "API for retrieving service providers for Neutron advanced services",
+			},
+		}
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+		"extension": {
+				"updated": "2013-02-03T10:00:00-00:00",
+				"name": "agent",
+				"links": [],
+				"namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+				"alias": "agent",
+				"description": "The agent management extension."
+		}
+}
+		`)
+	})
+
+	ext, err := Get(client.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, ext.Updated, "2013-02-03T10:00:00-00:00")
+	th.AssertEquals(t, ext.Name, "agent")
+	th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/agent/api/v2.0")
+	th.AssertEquals(t, ext.Alias, "agent")
+	th.AssertEquals(t, ext.Description, "The agent management extension.")
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/doc.go b/openstack/compute/v2/extensions/diskconfig/doc.go
new file mode 100644
index 0000000..80785fa
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/doc.go
@@ -0,0 +1,3 @@
+// Package diskconfig provides information and interaction with the Disk
+// Config extension that works with the OpenStack Compute service.
+package diskconfig
diff --git a/openstack/compute/v2/extensions/diskconfig/requests.go b/openstack/compute/v2/extensions/diskconfig/requests.go
new file mode 100644
index 0000000..41d04b9
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests.go
@@ -0,0 +1,103 @@
+package diskconfig
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+// DiskConfig represents one of the two possible settings for the DiskConfig option when creating,
+// rebuilding, or resizing servers: Auto or Manual.
+type DiskConfig string
+
+const (
+	// Auto builds a server with a single partition the size of the target flavor disk and
+	// automatically adjusts the filesystem to fit the entire partition. Auto may only be used with
+	// images and servers that use a single EXT3 partition.
+	Auto DiskConfig = "AUTO"
+
+	// Manual builds a server using whatever partition scheme and filesystem are present in the source
+	// image. If the target flavor disk is larger, the remaining space is left unpartitioned. This
+	// enables images to have non-EXT3 filesystems, multiple partitions, and so on, and enables you
+	// to manage the disk configuration. It also results in slightly shorter boot times.
+	Manual DiskConfig = "MANUAL"
+)
+
+// CreateOptsExt adds a DiskConfig option to the base CreateOpts.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+
+	// DiskConfig [optional] controls how the created server's disk is partitioned.
+	DiskConfig DiskConfig `json:"OS-DCF:diskConfig,omitempty"`
+}
+
+// ToServerCreateMap adds the diskconfig option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+	base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	if string(opts.DiskConfig) == "" {
+		return base, nil
+	}
+
+	serverMap := base["server"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base, nil
+}
+
+// RebuildOptsExt adds a DiskConfig option to the base RebuildOpts.
+type RebuildOptsExt struct {
+	servers.RebuildOptsBuilder
+	// DiskConfig controls how the rebuilt server's disk is partitioned.
+	DiskConfig DiskConfig `json:"OS-DCF:diskConfig,omitempty"`
+}
+
+// ToServerRebuildMap adds the diskconfig option to the base server rebuild options.
+func (opts RebuildOptsExt) ToServerRebuildMap() (map[string]interface{}, error) {
+	if opts.DiskConfig != Auto && opts.DiskConfig != Manual {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "diskconfig.RebuildOptsExt.DiskConfig"
+		err.Info = "Must be either diskconfig.Auto or diskconfig.Manual"
+		return nil, err
+	}
+
+	base, err := opts.RebuildOptsBuilder.ToServerRebuildMap()
+	if err != nil {
+		return nil, err
+	}
+
+	serverMap := base["rebuild"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base, nil
+}
+
+// ResizeOptsExt adds a DiskConfig option to the base server resize options.
+type ResizeOptsExt struct {
+	servers.ResizeOptsBuilder
+
+	// DiskConfig [optional] controls how the resized server's disk is partitioned.
+	DiskConfig DiskConfig
+}
+
+// ToServerResizeMap adds the diskconfig option to the base server creation options.
+func (opts ResizeOptsExt) ToServerResizeMap() (map[string]interface{}, error) {
+	if opts.DiskConfig != Auto && opts.DiskConfig != Manual {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "diskconfig.ResizeOptsExt.DiskConfig"
+		err.Info = "Must be either diskconfig.Auto or diskconfig.Manual"
+		return nil, err
+	}
+
+	base, err := opts.ResizeOptsBuilder.ToServerResizeMap()
+	if err != nil {
+		return nil, err
+	}
+
+	serverMap := base["resize"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base, nil
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/requests_test.go b/openstack/compute/v2/extensions/diskconfig/requests_test.go
new file mode 100644
index 0000000..2e3ae18
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests_test.go
@@ -0,0 +1,87 @@
+package diskconfig
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		DiskConfig:        Manual,
+	}
+
+	expected := `
+		{
+			"server": {
+				"name": "createdserver",
+				"imageRef": "asdfasdfasdf",
+				"flavorRef": "performance1-1",
+				"OS-DCF:diskConfig": "MANUAL"
+			}
+		}
+	`
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestRebuildOpts(t *testing.T) {
+	base := servers.RebuildOpts{
+		Name:      "rebuiltserver",
+		AdminPass: "swordfish",
+		ImageID:   "asdfasdfasdf",
+	}
+
+	ext := RebuildOptsExt{
+		RebuildOptsBuilder: base,
+		DiskConfig:         Auto,
+	}
+
+	actual, err := ext.ToServerRebuildMap()
+	th.AssertNoErr(t, err)
+
+	expected := `
+		{
+			"rebuild": {
+				"name": "rebuiltserver",
+				"imageRef": "asdfasdfasdf",
+				"adminPass": "swordfish",
+				"OS-DCF:diskConfig": "AUTO"
+			}
+		}
+	`
+	th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestResizeOpts(t *testing.T) {
+	base := servers.ResizeOpts{
+		FlavorRef: "performance1-8",
+	}
+
+	ext := ResizeOptsExt{
+		ResizeOptsBuilder: base,
+		DiskConfig:        Auto,
+	}
+
+	actual, err := ext.ToServerResizeMap()
+	th.AssertNoErr(t, err)
+
+	expected := `
+		{
+			"resize": {
+				"flavorRef": "performance1-8",
+				"OS-DCF:diskConfig": "AUTO"
+			}
+		}
+	`
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/results.go b/openstack/compute/v2/extensions/diskconfig/results.go
new file mode 100644
index 0000000..1957e12
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results.go
@@ -0,0 +1,8 @@
+package diskconfig
+
+import "github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+
+type ServerWithDiskConfig struct {
+	servers.Server
+	DiskConfig DiskConfig `json:"OS-DCF:diskConfig"`
+}
diff --git a/openstack/compute/v2/extensions/doc.go b/openstack/compute/v2/extensions/doc.go
new file mode 100644
index 0000000..2b447da
--- /dev/null
+++ b/openstack/compute/v2/extensions/doc.go
@@ -0,0 +1,3 @@
+// Package extensions provides information and interaction with the
+// different extensions available for the OpenStack Compute service.
+package extensions
diff --git a/openstack/compute/v2/extensions/floatingips/doc.go b/openstack/compute/v2/extensions/floatingips/doc.go
new file mode 100644
index 0000000..6682fa6
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/doc.go
@@ -0,0 +1,3 @@
+// Package floatingips provides the ability to manage floating ips through
+// nova-network
+package floatingips
diff --git a/openstack/compute/v2/extensions/floatingips/fixtures.go b/openstack/compute/v2/extensions/floatingips/fixtures.go
new file mode 100644
index 0000000..b369ea2
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/fixtures.go
@@ -0,0 +1,193 @@
+// +build fixtures
+
+package floatingips
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+    "floating_ips": [
+        {
+            "fixed_ip": null,
+            "id": "1",
+            "instance_id": null,
+            "ip": "10.10.10.1",
+            "pool": "nova"
+        },
+        {
+            "fixed_ip": "166.78.185.201",
+            "id": "2",
+            "instance_id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+            "ip": "10.10.10.2",
+            "pool": "nova"
+        }
+    ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+    "floating_ip": {
+        "fixed_ip": "166.78.185.201",
+        "id": "2",
+        "instance_id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+        "ip": "10.10.10.2",
+        "pool": "nova"
+    }
+}
+`
+
+// CreateOutput is a sample response to a Post call
+const CreateOutput = `
+{
+    "floating_ip": {
+        "fixed_ip": null,
+        "id": "1",
+        "instance_id": null,
+        "ip": "10.10.10.1",
+        "pool": "nova"
+    }
+}
+`
+
+// FirstFloatingIP is the first result in ListOutput.
+var FirstFloatingIP = FloatingIP{
+	ID:   "1",
+	IP:   "10.10.10.1",
+	Pool: "nova",
+}
+
+// SecondFloatingIP is the first result in ListOutput.
+var SecondFloatingIP = FloatingIP{
+	FixedIP:    "166.78.185.201",
+	ID:         "2",
+	InstanceID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+	IP:         "10.10.10.2",
+	Pool:       "nova",
+}
+
+// ExpectedFloatingIPsSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedFloatingIPsSlice = []FloatingIP{FirstFloatingIP, SecondFloatingIP}
+
+// CreatedFloatingIP is the parsed result from CreateOutput.
+var CreatedFloatingIP = FloatingIP{
+	ID:   "1",
+	IP:   "10.10.10.1",
+	Pool: "nova",
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-floating-ips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing floating ip
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-floating-ips/2", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new floating ip
+func HandleCreateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-floating-ips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+	"pool": "nova"
+}
+`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, CreateOutput)
+	})
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing floating ip
+func HandleDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-floating-ips/1", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleAssociateSuccessfully configures the test server to respond to a Post request
+// to associate an allocated floating IP
+func HandleAssociateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+	"addFloatingIp": {
+		"address": "10.10.10.2"
+	}
+}
+`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleFixedAssociateSucessfully configures the test server to respond to a Post request
+// to associate an allocated floating IP with a specific fixed IP address
+func HandleAssociateFixedSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+	"addFloatingIp": {
+		"address": "10.10.10.2",
+		"fixed_address": "166.78.185.201"
+	}
+}
+`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleDisassociateSuccessfully configures the test server to respond to a Post request
+// to disassociate an allocated floating IP
+func HandleDisassociateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+	"removeFloatingIp": {
+		"address": "10.10.10.2"
+	}
+}
+`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/extensions/floatingips/requests.go b/openstack/compute/v2/extensions/floatingips/requests.go
new file mode 100644
index 0000000..b36aeba
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/requests.go
@@ -0,0 +1,112 @@
+package floatingips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of FloatingIPs.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return FloatingIPPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	ToFloatingIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies a Floating IP allocation request
+type CreateOpts struct {
+	// Pool is the pool of floating IPs to allocate one from
+	Pool string `json:"pool" required:"true"`
+}
+
+// ToFloatingIPCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToFloatingIPCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Create requests the creation of a new floating IP
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToFloatingIPCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get returns data about a previously created FloatingIP.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete requests the deletion of a previous allocated FloatingIP.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// AssociateOptsBuilder is the interface types must satfisfy to be used as
+// Associate options
+type AssociateOptsBuilder interface {
+	ToFloatingIPAssociateMap() (map[string]interface{}, error)
+}
+
+// AssociateOpts specifies the required information to associate a floating IP with an instance
+type AssociateOpts struct {
+	// FloatingIP is the floating IP to associate with an instance
+	FloatingIP string `json:"address" required:"true"`
+	// FixedIP is an optional fixed IP address of the server
+	FixedIP string `json:"fixed_address,omitempty"`
+}
+
+// ToFloatingIPAssociateMap constructs a request body from AssociateOpts.
+func (opts AssociateOpts) ToFloatingIPAssociateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "addFloatingIp")
+}
+
+// AssociateInstance pairs an allocated floating IP with an instance.
+func AssociateInstance(client *gophercloud.ServiceClient, serverID string, opts AssociateOptsBuilder) (r AssociateResult) {
+	b, err := opts.ToFloatingIPAssociateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(associateURL(client, serverID), b, nil, nil)
+	return
+}
+
+// DisassociateOptsBuilder is the interface types must satfisfy to be used as
+// Disassociate options
+type DisassociateOptsBuilder interface {
+	ToFloatingIPDisassociateMap() (map[string]interface{}, error)
+}
+
+// DisassociateOpts specifies the required information to disassociate a floating IP with an instance
+type DisassociateOpts struct {
+	FloatingIP string `json:"address" required:"true"`
+}
+
+// ToFloatingIPDisassociateMap constructs a request body from AssociateOpts.
+func (opts DisassociateOpts) ToFloatingIPDisassociateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "removeFloatingIp")
+}
+
+// DisassociateInstance decouples an allocated floating IP from an instance
+func DisassociateInstance(client *gophercloud.ServiceClient, serverID string, opts DisassociateOptsBuilder) (r DisassociateResult) {
+	b, err := opts.ToFloatingIPDisassociateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(disassociateURL(client, serverID), b, nil, nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/floatingips/requests_test.go b/openstack/compute/v2/extensions/floatingips/requests_test.go
new file mode 100644
index 0000000..f0e9560
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/requests_test.go
@@ -0,0 +1,98 @@
+package floatingips
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractFloatingIPs(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedFloatingIPsSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateSuccessfully(t)
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Pool: "nova",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &CreatedFloatingIP, actual)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "2").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &SecondFloatingIP, actual)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteSuccessfully(t)
+
+	err := Delete(client.ServiceClient(), "1").ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAssociate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAssociateSuccessfully(t)
+
+	associateOpts := AssociateOpts{
+		FloatingIP: "10.10.10.2",
+	}
+
+	err := AssociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", associateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAssociateFixed(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAssociateFixedSuccessfully(t)
+
+	associateOpts := AssociateOpts{
+		FloatingIP: "10.10.10.2",
+		FixedIP:    "166.78.185.201",
+	}
+
+	err := AssociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", associateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestDisassociateInstance(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDisassociateSuccessfully(t)
+
+	disassociateOpts := DisassociateOpts{
+		FloatingIP: "10.10.10.2",
+	}
+
+	err := DisassociateInstance(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0", disassociateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/floatingips/results.go b/openstack/compute/v2/extensions/floatingips/results.go
new file mode 100644
index 0000000..753f3af
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/results.go
@@ -0,0 +1,91 @@
+package floatingips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// A FloatingIP is an IP that can be associated with an instance
+type FloatingIP struct {
+	// ID is a unique ID of the Floating IP
+	ID string `json:"id"`
+
+	// FixedIP is the IP of the instance related to the Floating IP
+	FixedIP string `json:"fixed_ip,omitempty"`
+
+	// InstanceID is the ID of the instance that is using the Floating IP
+	InstanceID string `json:"instance_id"`
+
+	// IP is the actual Floating IP
+	IP string `json:"ip"`
+
+	// Pool is the pool of floating IPs that this floating IP belongs to
+	Pool string `json:"pool"`
+}
+
+// FloatingIPPage stores a single, only page of FloatingIPs
+// results from a List call.
+type FloatingIPPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a FloatingIPsPage is empty.
+func (page FloatingIPPage) IsEmpty() (bool, error) {
+	va, err := ExtractFloatingIPs(page)
+	return len(va) == 0, err
+}
+
+// ExtractFloatingIPs interprets a page of results as a slice of
+// FloatingIPs.
+func ExtractFloatingIPs(r pagination.Page) ([]FloatingIP, error) {
+	var s struct {
+		FloatingIPs []FloatingIP `json:"floating_ips"`
+	}
+	err := (r.(FloatingIPPage)).ExtractInto(&s)
+	return s.FloatingIPs, err
+}
+
+// FloatingIPResult is the raw result from a FloatingIP request.
+type FloatingIPResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any FloatingIP resource
+// response as a FloatingIP struct.
+func (r FloatingIPResult) Extract() (*FloatingIP, error) {
+	var s struct {
+		FloatingIP *FloatingIP `json:"floating_ip"`
+	}
+	err := r.ExtractInto(&s)
+	return s.FloatingIP, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a FloatingIP.
+type CreateResult struct {
+	FloatingIPResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a FloatingIP.
+type GetResult struct {
+	FloatingIPResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// AssociateResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type AssociateResult struct {
+	gophercloud.ErrResult
+}
+
+// DisassociateResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DisassociateResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/floatingips/urls.go b/openstack/compute/v2/extensions/floatingips/urls.go
new file mode 100644
index 0000000..4768e5a
--- /dev/null
+++ b/openstack/compute/v2/extensions/floatingips/urls.go
@@ -0,0 +1,37 @@
+package floatingips
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-floating-ips"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return getURL(c, id)
+}
+
+func serverURL(c *gophercloud.ServiceClient, serverID string) string {
+	return c.ServiceURL("servers/" + serverID + "/action")
+}
+
+func associateURL(c *gophercloud.ServiceClient, serverID string) string {
+	return serverURL(c, serverID)
+}
+
+func disassociateURL(c *gophercloud.ServiceClient, serverID string) string {
+	return serverURL(c, serverID)
+}
diff --git a/openstack/compute/v2/extensions/keypairs/doc.go b/openstack/compute/v2/extensions/keypairs/doc.go
new file mode 100644
index 0000000..856f41b
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/doc.go
@@ -0,0 +1,3 @@
+// Package keypairs provides information and interaction with the Keypairs
+// extension for the OpenStack Compute service.
+package keypairs
diff --git a/openstack/compute/v2/extensions/keypairs/fixtures.go b/openstack/compute/v2/extensions/keypairs/fixtures.go
new file mode 100644
index 0000000..62c5db2
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/fixtures.go
@@ -0,0 +1,171 @@
+// +build fixtures
+
+package keypairs
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+	"keypairs": [
+		{
+			"keypair": {
+				"fingerprint": "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a",
+				"name": "firstkey",
+				"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n"
+			}
+		},
+		{
+			"keypair": {
+				"fingerprint": "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+				"name": "secondkey",
+				"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n"
+			}
+		}
+	]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+	"keypair": {
+		"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n",
+		"name": "firstkey",
+		"fingerprint": "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a"
+	}
+}
+`
+
+// CreateOutput is a sample response to a Create call.
+const CreateOutput = `
+{
+	"keypair": {
+		"fingerprint": "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+		"name": "createdkey",
+		"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7\nDUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ\n9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5QIDAQAB\nAoGAE5XO1mDhORy9COvsg+kYPUhB1GsCYxh+v88wG7HeFDKBY6KUc/Kxo6yoGn5T\nTjRjekyi2KoDZHz4VlIzyZPwFS4I1bf3oCunVoAKzgLdmnTtvRNMC5jFOGc2vUgP\n9bSyRj3S1R4ClVk2g0IDeagko/jc8zzLEYuIK+fbkds79YECQQDt3vcevgegnkga\ntF4NsDmmBPRkcSHCqrANP/7vFcBQN3czxeYYWX3DK07alu6GhH1Y4sHbdm616uU0\nll7xbDzxAkEAzAtN2IyftNygV2EGiaGgqLyo/tD9+Vui2qCQplqe4jvWh/5Sparl\nOjmKo+uAW+hLrLVMnHzRWxbWU8hirH5FNQJATO+ZxCK4etXXAnQmG41NCAqANWB2\nB+2HJbH2NcQ2QHvAHUm741JGn/KI/aBlo7KEjFRDWUVUB5ji64BbUwCsMQJBAIku\nLGcjnBf/oLk+XSPZC2eGd2Ph5G5qYmH0Q2vkTx+wtTn3DV+eNsDfgMtWAJVJ5t61\ngU1QSXyhLPVlKpnnxuUCQC+xvvWjWtsLaFtAsZywJiqLxQzHts8XLGZptYJ5tLWV\nrtmYtBcJCN48RrgQHry/xWYeA4K/AFQpXfNPgprQ96Q=\n-----END RSA PRIVATE KEY-----\n",
+		"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+		"user_id": "fake"
+	}
+}
+`
+
+// ImportOutput is a sample response to a Create call that provides its own public key.
+const ImportOutput = `
+{
+	"keypair": {
+		"fingerprint": "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c",
+		"name": "importedkey",
+		"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+		"user_id": "fake"
+	}
+}
+`
+
+// FirstKeyPair is the first result in ListOutput.
+var FirstKeyPair = KeyPair{
+	Name:        "firstkey",
+	Fingerprint: "15:b0:f8:b3:f9:48:63:71:cf:7b:5b:38:6d:44:2d:4a",
+	PublicKey:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC+Eo/RZRngaGTkFs7I62ZjsIlO79KklKbMXi8F+KITD4bVQHHn+kV+4gRgkgCRbdoDqoGfpaDFs877DYX9n4z6FrAIZ4PES8TNKhatifpn9NdQYWA+IkU8CuvlEKGuFpKRi/k7JLos/gHi2hy7QUwgtRvcefvD/vgQZOVw/mGR9Q== Generated by Nova\n",
+}
+
+// SecondKeyPair is the second result in ListOutput.
+var SecondKeyPair = KeyPair{
+	Name:        "secondkey",
+	Fingerprint: "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+	PublicKey:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+}
+
+// ExpectedKeyPairSlice is the slice of results that should be parsed from ListOutput, in the expected
+// order.
+var ExpectedKeyPairSlice = []KeyPair{FirstKeyPair, SecondKeyPair}
+
+// CreatedKeyPair is the parsed result from CreatedOutput.
+var CreatedKeyPair = KeyPair{
+	Name:        "createdkey",
+	Fingerprint: "35:9d:d0:c3:4a:80:d3:d8:86:f1:ca:f7:df:c4:f9:d8",
+	PublicKey:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7DUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5Q== Generated by Nova\n",
+	PrivateKey:  "-----BEGIN RSA PRIVATE KEY-----\nMIICXAIBAAKBgQC9mC3WZN9UGLxgPBpP7H5jZMc6pKwOoSgre8yun6REFktn/Kz7\nDUt9jaR1UJyRzHxITfCfAIgSxPdGqB/oF1suMyWgu5i0625vavLB5z5kC8Hq3qZJ\n9zJO1poE1kyD+htiTtPWJ88e12xuH2XB/CZN9OpEiF98hAagiOE0EnOS5QIDAQAB\nAoGAE5XO1mDhORy9COvsg+kYPUhB1GsCYxh+v88wG7HeFDKBY6KUc/Kxo6yoGn5T\nTjRjekyi2KoDZHz4VlIzyZPwFS4I1bf3oCunVoAKzgLdmnTtvRNMC5jFOGc2vUgP\n9bSyRj3S1R4ClVk2g0IDeagko/jc8zzLEYuIK+fbkds79YECQQDt3vcevgegnkga\ntF4NsDmmBPRkcSHCqrANP/7vFcBQN3czxeYYWX3DK07alu6GhH1Y4sHbdm616uU0\nll7xbDzxAkEAzAtN2IyftNygV2EGiaGgqLyo/tD9+Vui2qCQplqe4jvWh/5Sparl\nOjmKo+uAW+hLrLVMnHzRWxbWU8hirH5FNQJATO+ZxCK4etXXAnQmG41NCAqANWB2\nB+2HJbH2NcQ2QHvAHUm741JGn/KI/aBlo7KEjFRDWUVUB5ji64BbUwCsMQJBAIku\nLGcjnBf/oLk+XSPZC2eGd2Ph5G5qYmH0Q2vkTx+wtTn3DV+eNsDfgMtWAJVJ5t61\ngU1QSXyhLPVlKpnnxuUCQC+xvvWjWtsLaFtAsZywJiqLxQzHts8XLGZptYJ5tLWV\nrtmYtBcJCN48RrgQHry/xWYeA4K/AFQpXfNPgprQ96Q=\n-----END RSA PRIVATE KEY-----\n",
+	UserID:      "fake",
+}
+
+// ImportedKeyPair is the parsed result from ImportOutput.
+var ImportedKeyPair = KeyPair{
+	Name:        "importedkey",
+	Fingerprint: "1e:2c:9b:56:79:4b:45:77:f9:ca:7a:98:2c:b0:d5:3c",
+	PublicKey:   "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+	UserID:      "fake",
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request for "firstkey".
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-keypairs/firstkey", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request for a new
+// keypair called "createdkey".
+func HandleCreateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "keypair": { "name": "createdkey" } }`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, CreateOutput)
+	})
+}
+
+// HandleImportSuccessfully configures the test server to respond to an Import request for an
+// existing keypair called "importedkey".
+func HandleImportSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-keypairs", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+			{
+				"keypair": {
+					"name": "importedkey",
+					"public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova"
+				}
+			}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ImportOutput)
+	})
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// keypair called "deletedkey".
+func HandleDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-keypairs/deletedkey", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/extensions/keypairs/requests.go b/openstack/compute/v2/extensions/keypairs/requests.go
new file mode 100644
index 0000000..adf1e55
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/requests.go
@@ -0,0 +1,84 @@
+package keypairs
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsExt adds a KeyPair option to the base CreateOpts.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+	KeyName string `json:"key_name,omitempty"`
+}
+
+// ToServerCreateMap adds the key_name and, optionally, key_data options to
+// the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+	base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	if opts.KeyName == "" {
+		return base, nil
+	}
+
+	serverMap := base["server"].(map[string]interface{})
+	serverMap["key_name"] = opts.KeyName
+
+	return base, nil
+}
+
+// List returns a Pager that allows you to iterate over a collection of KeyPairs.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return KeyPairPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	ToKeyPairCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies keypair creation or import parameters.
+type CreateOpts struct {
+	// Name is a friendly name to refer to this KeyPair in other services.
+	Name string `json:"name" required:"true"`
+	// PublicKey [optional] is a pregenerated OpenSSH-formatted public key. If provided, this key
+	// will be imported and no new key will be created.
+	PublicKey string `json:"public_key,omitempty"`
+}
+
+// ToKeyPairCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToKeyPairCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "keypair")
+}
+
+// Create requests the creation of a new keypair on the server, or to import a pre-existing
+// keypair.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToKeyPairCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get returns public data about a previously uploaded KeyPair.
+func Get(client *gophercloud.ServiceClient, name string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, name), &r.Body, nil)
+	return
+}
+
+// Delete requests the deletion of a previous stored KeyPair from the server.
+func Delete(client *gophercloud.ServiceClient, name string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, name), nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/keypairs/requests_test.go b/openstack/compute/v2/extensions/keypairs/requests_test.go
new file mode 100644
index 0000000..468e5ea
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/requests_test.go
@@ -0,0 +1,71 @@
+package keypairs
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractKeyPairs(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedKeyPairSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateSuccessfully(t)
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Name: "createdkey",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &CreatedKeyPair, actual)
+}
+
+func TestImport(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleImportSuccessfully(t)
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Name:      "importedkey",
+		PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDx8nkQv/zgGgB4rMYmIf+6A4l6Rr+o/6lHBQdW5aYd44bd8JttDCE/F/pNRr0lRE+PiqSPO8nDPHw0010JeMH9gYgnnFlyY3/OcJ02RhIPyyxYpv9FhY+2YiUkpwFOcLImyrxEsYXpD/0d3ac30bNH6Sw9JD9UZHYcpSxsIbECHw== Generated by Nova",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &ImportedKeyPair, actual)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "firstkey").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &FirstKeyPair, actual)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteSuccessfully(t)
+
+	err := Delete(client.ServiceClient(), "deletedkey").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/keypairs/results.go b/openstack/compute/v2/extensions/keypairs/results.go
new file mode 100644
index 0000000..f4d8d35
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/results.go
@@ -0,0 +1,86 @@
+package keypairs
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// KeyPair is an SSH key known to the OpenStack cluster that is available to be injected into
+// servers.
+type KeyPair struct {
+	// Name is used to refer to this keypair from other services within this region.
+	Name string `json:"name"`
+
+	// Fingerprint is a short sequence of bytes that can be used to authenticate or validate a longer
+	// public key.
+	Fingerprint string `json:"fingerprint"`
+
+	// PublicKey is the public key from this pair, in OpenSSH format. "ssh-rsa AAAAB3Nz..."
+	PublicKey string `json:"public_key"`
+
+	// PrivateKey is the private key from this pair, in PEM format.
+	// "-----BEGIN RSA PRIVATE KEY-----\nMIICXA..." It is only present if this keypair was just
+	// returned from a Create call
+	PrivateKey string `json:"private_key"`
+
+	// UserID is the user who owns this keypair.
+	UserID string `json:"user_id"`
+}
+
+// KeyPairPage stores a single, only page of KeyPair results from a List call.
+type KeyPairPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a KeyPairPage is empty.
+func (page KeyPairPage) IsEmpty() (bool, error) {
+	ks, err := ExtractKeyPairs(page)
+	return len(ks) == 0, err
+}
+
+// ExtractKeyPairs interprets a page of results as a slice of KeyPairs.
+func ExtractKeyPairs(r pagination.Page) ([]KeyPair, error) {
+	type pair struct {
+		KeyPair KeyPair `json:"keypair"`
+	}
+	var s struct {
+		KeyPairs []pair `json:"keypairs"`
+	}
+	err := (r.(KeyPairPage)).ExtractInto(&s)
+	results := make([]KeyPair, len(s.KeyPairs))
+	for i, pair := range s.KeyPairs {
+		results[i] = pair.KeyPair
+	}
+	return results, err
+}
+
+type keyPairResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any KeyPair resource response as a KeyPair struct.
+func (r keyPairResult) Extract() (*KeyPair, error) {
+	var s struct {
+		KeyPair *KeyPair `json:"keypair"`
+	}
+	err := r.ExtractInto(&s)
+	return s.KeyPair, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a KeyPair.
+type CreateResult struct {
+	keyPairResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a KeyPair.
+type GetResult struct {
+	keyPairResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/keypairs/urls.go b/openstack/compute/v2/extensions/keypairs/urls.go
new file mode 100644
index 0000000..fec38f3
--- /dev/null
+++ b/openstack/compute/v2/extensions/keypairs/urls.go
@@ -0,0 +1,25 @@
+package keypairs
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-keypairs"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, name string) string {
+	return c.ServiceURL(resourcePath, name)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, name string) string {
+	return getURL(c, name)
+}
diff --git a/openstack/compute/v2/extensions/networks/doc.go b/openstack/compute/v2/extensions/networks/doc.go
new file mode 100644
index 0000000..fafe4a0
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/doc.go
@@ -0,0 +1,2 @@
+// Package network provides the ability to manage nova-networks
+package networks
diff --git a/openstack/compute/v2/extensions/networks/fixtures.go b/openstack/compute/v2/extensions/networks/fixtures.go
new file mode 100644
index 0000000..ffa4282
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/fixtures.go
@@ -0,0 +1,205 @@
+// +build fixtures
+
+package networks
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+    "networks": [
+        {
+            "bridge": "br100",
+            "bridge_interface": "eth0",
+            "broadcast": "10.0.0.7",
+            "cidr": "10.0.0.0/29",
+            "cidr_v6": null,
+            "created_at": "2011-08-15T06:19:19.387525",
+            "deleted": false,
+            "dhcp_start": "10.0.0.3",
+            "dns1": null,
+            "dns2": null,
+            "gateway": "10.0.0.1",
+            "gateway_v6": null,
+            "host": "nsokolov-desktop",
+            "id": "20c8acc0-f747-4d71-a389-46d078ebf047",
+            "injected": false,
+            "label": "mynet_0",
+            "multi_host": false,
+            "netmask": "255.255.255.248",
+            "netmask_v6": null,
+            "priority": null,
+            "project_id": "1234",
+            "rxtx_base": null,
+            "updated_at": "2011-08-16T09:26:13.048257",
+            "vlan": 100,
+            "vpn_private_address": "10.0.0.2",
+            "vpn_public_address": "127.0.0.1",
+            "vpn_public_port": 1000
+        },
+        {
+            "bridge": "br101",
+            "bridge_interface": "eth0",
+            "broadcast": "10.0.0.15",
+            "cidr": "10.0.0.10/29",
+            "cidr_v6": null,
+            "created_at": "2011-08-15T06:19:19.387525",
+            "deleted": false,
+            "dhcp_start": "10.0.0.11",
+            "dns1": null,
+            "dns2": null,
+            "gateway": "10.0.0.9",
+            "gateway_v6": null,
+            "host": null,
+            "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+            "injected": false,
+            "label": "mynet_1",
+            "multi_host": false,
+            "netmask": "255.255.255.248",
+            "netmask_v6": null,
+            "priority": null,
+            "project_id": null,
+            "rxtx_base": null,
+            "vlan": 101,
+            "vpn_private_address": "10.0.0.10",
+            "vpn_public_address": null,
+            "vpn_public_port": 1001
+        }
+    ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+    "network": {
+			"bridge": "br101",
+			"bridge_interface": "eth0",
+			"broadcast": "10.0.0.15",
+			"cidr": "10.0.0.10/29",
+			"cidr_v6": null,
+			"created_at": "2011-08-15T06:19:19.387525",
+			"deleted": false,
+			"dhcp_start": "10.0.0.11",
+			"dns1": null,
+			"dns2": null,
+			"gateway": "10.0.0.9",
+			"gateway_v6": null,
+			"host": null,
+			"id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+			"injected": false,
+			"label": "mynet_1",
+			"multi_host": false,
+			"netmask": "255.255.255.248",
+			"netmask_v6": null,
+			"priority": null,
+			"project_id": null,
+			"rxtx_base": null,
+			"vlan": 101,
+			"vpn_private_address": "10.0.0.10",
+			"vpn_public_address": null,
+			"vpn_public_port": 1001
+		}
+}
+`
+
+// FirstNetwork is the first result in ListOutput.
+var nilTime time.Time
+var FirstNetwork = Network{
+	Bridge:            "br100",
+	BridgeInterface:   "eth0",
+	Broadcast:         "10.0.0.7",
+	CIDR:              "10.0.0.0/29",
+	CIDRv6:            "",
+	CreatedAt:         gophercloud.JSONRFC3339MilliNoZ(time.Date(2011, 8, 15, 6, 19, 19, 387525000, time.UTC)),
+	Deleted:           false,
+	DeletedAt:         gophercloud.JSONRFC3339MilliNoZ(nilTime),
+	DHCPStart:         "10.0.0.3",
+	DNS1:              "",
+	DNS2:              "",
+	Gateway:           "10.0.0.1",
+	Gatewayv6:         "",
+	Host:              "nsokolov-desktop",
+	ID:                "20c8acc0-f747-4d71-a389-46d078ebf047",
+	Injected:          false,
+	Label:             "mynet_0",
+	MultiHost:         false,
+	Netmask:           "255.255.255.248",
+	Netmaskv6:         "",
+	Priority:          0,
+	ProjectID:         "1234",
+	RXTXBase:          0,
+	UpdatedAt:         gophercloud.JSONRFC3339MilliNoZ(time.Date(2011, 8, 16, 9, 26, 13, 48257000, time.UTC)),
+	VLAN:              100,
+	VPNPrivateAddress: "10.0.0.2",
+	VPNPublicAddress:  "127.0.0.1",
+	VPNPublicPort:     1000,
+}
+
+// SecondNetwork is the second result in ListOutput.
+var SecondNetwork = Network{
+	Bridge:            "br101",
+	BridgeInterface:   "eth0",
+	Broadcast:         "10.0.0.15",
+	CIDR:              "10.0.0.10/29",
+	CIDRv6:            "",
+	CreatedAt:         gophercloud.JSONRFC3339MilliNoZ(time.Date(2011, 8, 15, 6, 19, 19, 387525000, time.UTC)),
+	Deleted:           false,
+	DeletedAt:         gophercloud.JSONRFC3339MilliNoZ(nilTime),
+	DHCPStart:         "10.0.0.11",
+	DNS1:              "",
+	DNS2:              "",
+	Gateway:           "10.0.0.9",
+	Gatewayv6:         "",
+	Host:              "",
+	ID:                "20c8acc0-f747-4d71-a389-46d078ebf000",
+	Injected:          false,
+	Label:             "mynet_1",
+	MultiHost:         false,
+	Netmask:           "255.255.255.248",
+	Netmaskv6:         "",
+	Priority:          0,
+	ProjectID:         "",
+	RXTXBase:          0,
+	UpdatedAt:         gophercloud.JSONRFC3339MilliNoZ(nilTime),
+	VLAN:              101,
+	VPNPrivateAddress: "10.0.0.10",
+	VPNPublicAddress:  "",
+	VPNPublicPort:     1001,
+}
+
+// ExpectedNetworkSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedNetworkSlice = []Network{FirstNetwork, SecondNetwork}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing network.
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-networks/20c8acc0-f747-4d71-a389-46d078ebf000", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
diff --git a/openstack/compute/v2/extensions/networks/requests.go b/openstack/compute/v2/extensions/networks/requests.go
new file mode 100644
index 0000000..5432a10
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/requests.go
@@ -0,0 +1,19 @@
+package networks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return NetworkPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/networks/requests_test.go b/openstack/compute/v2/extensions/networks/requests_test.go
new file mode 100644
index 0000000..3afc5d5
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/requests_test.go
@@ -0,0 +1,37 @@
+package networks
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNetworks(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedNetworkSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "20c8acc0-f747-4d71-a389-46d078ebf000").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &SecondNetwork, actual)
+}
diff --git a/openstack/compute/v2/extensions/networks/results.go b/openstack/compute/v2/extensions/networks/results.go
new file mode 100644
index 0000000..cbcce31
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/results.go
@@ -0,0 +1,134 @@
+package networks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// A Network represents a nova-network that an instance communicates on
+type Network struct {
+	// The Bridge that VIFs on this network are connected to
+	Bridge string `json:"bridge"`
+
+	// BridgeInterface is what interface is connected to the Bridge
+	BridgeInterface string `json:"bridge_interface"`
+
+	// The Broadcast address of the network.
+	Broadcast string `json:"broadcast"`
+
+	// CIDR is the IPv4 subnet.
+	CIDR string `json:"cidr"`
+
+	// CIDRv6 is the IPv6 subnet.
+	CIDRv6 string `json:"cidr_v6"`
+
+	// CreatedAt is when the network was created..
+	CreatedAt gophercloud.JSONRFC3339MilliNoZ `json:"created_at,omitempty"`
+
+	// Deleted shows if the network has been deleted.
+	Deleted bool `json:"deleted"`
+
+	// DeletedAt is the time when the network was deleted.
+	DeletedAt gophercloud.JSONRFC3339MilliNoZ `json:"deleted_at,omitempty"`
+
+	// DHCPStart is the start of the DHCP address range.
+	DHCPStart string `json:"dhcp_start"`
+
+	// DNS1 is the first DNS server to use through DHCP.
+	DNS1 string `json:"dns_1"`
+
+	// DNS2 is the first DNS server to use through DHCP.
+	DNS2 string `json:"dns_2"`
+
+	// Gateway is the network gateway.
+	Gateway string `json:"gateway"`
+
+	// Gatewayv6 is the IPv6 network gateway.
+	Gatewayv6 string `json:"gateway_v6"`
+
+	// Host is the host that the network service is running on.
+	Host string `json:"host"`
+
+	// ID is the UUID of the network.
+	ID string `json:"id"`
+
+	// Injected determines if network information is injected into the host.
+	Injected bool `json:"injected"`
+
+	// Label is the common name that the network has..
+	Label string `json:"label"`
+
+	// MultiHost is if multi-host networking is enablec..
+	MultiHost bool `json:"multi_host"`
+
+	// Netmask is the network netmask.
+	Netmask string `json:"netmask"`
+
+	// Netmaskv6 is the IPv6 netmask.
+	Netmaskv6 string `json:"netmask_v6"`
+
+	// Priority is the network interface priority.
+	Priority int `json:"priority"`
+
+	// ProjectID is the project associated with this network.
+	ProjectID string `json:"project_id"`
+
+	// RXTXBase configures bandwidth entitlement.
+	RXTXBase int `json:"rxtx_base"`
+
+	// UpdatedAt is the time when the network was last updated.
+	UpdatedAt gophercloud.JSONRFC3339MilliNoZ `json:"updated_at,omitempty"`
+
+	// VLAN is the vlan this network runs on.
+	VLAN int `json:"vlan"`
+
+	// VPNPrivateAddress is the private address of the CloudPipe VPN.
+	VPNPrivateAddress string `json:"vpn_private_address"`
+
+	// VPNPublicAddress is the public address of the CloudPipe VPN.
+	VPNPublicAddress string `json:"vpn_public_address"`
+
+	// VPNPublicPort is the port of the CloudPipe VPN.
+	VPNPublicPort int `json:"vpn_public_port"`
+}
+
+// NetworkPage stores a single, only page of Networks
+// results from a List call.
+type NetworkPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a NetworkPage is empty.
+func (page NetworkPage) IsEmpty() (bool, error) {
+	va, err := ExtractNetworks(page)
+	return len(va) == 0, err
+}
+
+// ExtractNetworks interprets a page of results as a slice of Networks
+func ExtractNetworks(r pagination.Page) ([]Network, error) {
+	var s struct {
+		Networks []Network `json:"networks"`
+	}
+	err := (r.(NetworkPage)).ExtractInto(&s)
+	return s.Networks, err
+}
+
+type NetworkResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Network resource
+// response as a Network struct.
+func (r NetworkResult) Extract() (*Network, error) {
+	var s struct {
+		Network *Network `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a Network.
+type GetResult struct {
+	NetworkResult
+}
diff --git a/openstack/compute/v2/extensions/networks/urls.go b/openstack/compute/v2/extensions/networks/urls.go
new file mode 100644
index 0000000..491bde6
--- /dev/null
+++ b/openstack/compute/v2/extensions/networks/urls.go
@@ -0,0 +1,17 @@
+package networks
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-networks"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/compute/v2/extensions/schedulerhints/doc.go b/openstack/compute/v2/extensions/schedulerhints/doc.go
new file mode 100644
index 0000000..0bd4566
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/doc.go
@@ -0,0 +1,3 @@
+// Package schedulerhints enables instances to provide the OpenStack scheduler
+// hints about where they should be placed in the cloud.
+package schedulerhints
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests.go b/openstack/compute/v2/extensions/schedulerhints/requests.go
new file mode 100644
index 0000000..1e06b46
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests.go
@@ -0,0 +1,148 @@
+package schedulerhints
+
+import (
+	"net"
+	"regexp"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+)
+
+// SchedulerHints represents a set of scheduling hints that are passed to the
+// OpenStack scheduler
+type SchedulerHints struct {
+	// Group specifies a Server Group to place the instance in.
+	Group string
+	// DifferentHost will place the instance on a compute node that does not
+	// host the given instances.
+	DifferentHost []string
+	// SameHost will place the instance on a compute node that hosts the given
+	// instances.
+	SameHost []string
+	// Query is a conditional statement that results in compute nodes able to
+	// host the instance.
+	Query []interface{}
+	// TargetCell specifies a cell name where the instance will be placed.
+	TargetCell string `json:"target_cell,omitempty"`
+	// BuildNearHostIP specifies a subnet of compute nodes to host the instance.
+	BuildNearHostIP string
+}
+
+// CreateOptsBuilder builds the scheduler hints into a serializable format.
+type CreateOptsBuilder interface {
+	ToServerSchedulerHintsCreateMap() (map[string]interface{}, error)
+}
+
+// ToServerSchedulerHintsMap builds the scheduler hints into a serializable format.
+func (opts SchedulerHints) ToServerSchedulerHintsCreateMap() (map[string]interface{}, error) {
+	sh := make(map[string]interface{})
+
+	uuidRegex, _ := regexp.Compile("^[a-z0-9]{8}-[a-z0-9]{4}-[1-5][a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{12}$")
+
+	if opts.Group != "" {
+		if !uuidRegex.MatchString(opts.Group) {
+			err := gophercloud.ErrInvalidInput{}
+			err.Argument = "schedulerhints.SchedulerHints.Group"
+			err.Value = opts.Group
+			err.Info = "Group must be a UUID"
+			return nil, err
+		}
+		sh["group"] = opts.Group
+	}
+
+	if len(opts.DifferentHost) > 0 {
+		for _, diffHost := range opts.DifferentHost {
+			if !uuidRegex.MatchString(diffHost) {
+				err := gophercloud.ErrInvalidInput{}
+				err.Argument = "schedulerhints.SchedulerHints.DifferentHost"
+				err.Value = opts.DifferentHost
+				err.Info = "The hosts must be in UUID format."
+				return nil, err
+			}
+		}
+		sh["different_host"] = opts.DifferentHost
+	}
+
+	if len(opts.SameHost) > 0 {
+		for _, sameHost := range opts.SameHost {
+			if !uuidRegex.MatchString(sameHost) {
+				err := gophercloud.ErrInvalidInput{}
+				err.Argument = "schedulerhints.SchedulerHints.SameHost"
+				err.Value = opts.SameHost
+				err.Info = "The hosts must be in UUID format."
+				return nil, err
+			}
+		}
+		sh["same_host"] = opts.SameHost
+	}
+
+	/* Query can be something simple like:
+	     [">=", "$free_ram_mb", 1024]
+
+			Or more complex like:
+				['and',
+					['>=', '$free_ram_mb', 1024],
+					['>=', '$free_disk_mb', 200 * 1024]
+				]
+
+		Because of the possible complexity, just make sure the length is a minimum of 3.
+	*/
+	if len(opts.Query) > 0 {
+		if len(opts.Query) < 3 {
+			err := gophercloud.ErrInvalidInput{}
+			err.Argument = "schedulerhints.SchedulerHints.Query"
+			err.Value = opts.Query
+			err.Info = "Must be a conditional statement in the format of [op,variable,value]"
+			return nil, err
+		}
+		sh["query"] = opts.Query
+	}
+
+	if opts.TargetCell != "" {
+		sh["target_cell"] = opts.TargetCell
+	}
+
+	if opts.BuildNearHostIP != "" {
+		if _, _, err := net.ParseCIDR(opts.BuildNearHostIP); err != nil {
+			err := gophercloud.ErrInvalidInput{}
+			err.Argument = "schedulerhints.SchedulerHints.BuildNearHostIP"
+			err.Value = opts.BuildNearHostIP
+			err.Info = "Must be a valid subnet in the form 192.168.1.1/24"
+			return nil, err
+		}
+		ipParts := strings.Split(opts.BuildNearHostIP, "/")
+		sh["build_near_host_ip"] = ipParts[0]
+		sh["cidr"] = "/" + ipParts[1]
+	}
+
+	return sh, nil
+}
+
+// CreateOptsExt adds a SchedulerHints option to the base CreateOpts.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+	// SchedulerHints provides a set of hints to the scheduler.
+	SchedulerHints CreateOptsBuilder
+}
+
+// ToServerCreateMap adds the SchedulerHints option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+	base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	schedulerHints, err := opts.SchedulerHints.ToServerSchedulerHintsCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(schedulerHints) == 0 {
+		return base, nil
+	}
+
+	base["os:scheduler_hints"] = schedulerHints
+
+	return base, nil
+}
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests_test.go b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
new file mode 100644
index 0000000..605b72b
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
@@ -0,0 +1,126 @@
+package schedulerhints
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/servers"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	schedulerHints := SchedulerHints{
+		Group: "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+		DifferentHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		SameHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		Query:           []interface{}{">=", "$free_ram_mb", "1024"},
+		TargetCell:      "foobar",
+		BuildNearHostIP: "192.168.1.1/24",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		SchedulerHints:    schedulerHints,
+	}
+
+	expected := `
+		{
+			"server": {
+				"name": "createdserver",
+				"imageRef": "asdfasdfasdf",
+				"flavorRef": "performance1-1"
+			},
+			"os:scheduler_hints": {
+				"group": "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+				"different_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"same_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"query": [
+					">=", "$free_ram_mb", "1024"
+				],
+				"target_cell": "foobar",
+				"build_near_host_ip": "192.168.1.1",
+				"cidr": "/24"
+			}
+		}
+	`
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestCreateOptsWithComplexQuery(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	schedulerHints := SchedulerHints{
+		Group: "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+		DifferentHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		SameHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		Query:           []interface{}{"and", []string{">=", "$free_ram_mb", "1024"}, []string{">=", "$free_disk_mb", "204800"}},
+		TargetCell:      "foobar",
+		BuildNearHostIP: "192.168.1.1/24",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		SchedulerHints:    schedulerHints,
+	}
+
+	expected := `
+		{
+			"server": {
+				"name": "createdserver",
+				"imageRef": "asdfasdfasdf",
+				"flavorRef": "performance1-1"
+			},
+			"os:scheduler_hints": {
+				"group": "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+				"different_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"same_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"query": [
+					"and",
+					[">=", "$free_ram_mb", "1024"],
+					[">=", "$free_disk_mb", "204800"]
+				],
+				"target_cell": "foobar",
+				"build_near_host_ip": "192.168.1.1",
+				"cidr": "/24"
+			}
+		}
+	`
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/secgroups/doc.go b/openstack/compute/v2/extensions/secgroups/doc.go
new file mode 100644
index 0000000..702f32c
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/doc.go
@@ -0,0 +1 @@
+package secgroups
diff --git a/openstack/compute/v2/extensions/secgroups/fixtures.go b/openstack/compute/v2/extensions/secgroups/fixtures.go
new file mode 100644
index 0000000..0f97ac8
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/fixtures.go
@@ -0,0 +1,267 @@
+package secgroups
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const rootPath = "/os-security-groups"
+
+const listGroupsJSON = `
+{
+  "security_groups": [
+    {
+      "description": "default",
+      "id": "{groupID}",
+      "name": "default",
+      "rules": [],
+      "tenant_id": "openstack"
+    }
+  ]
+}
+`
+
+func mockListGroupsResponse(t *testing.T) {
+	th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, listGroupsJSON)
+	})
+}
+
+func mockListGroupsByServerResponse(t *testing.T, serverID string) {
+	url := fmt.Sprintf("/servers/%s%s", serverID, rootPath)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, listGroupsJSON)
+	})
+}
+
+func mockCreateGroupResponse(t *testing.T) {
+	th.Mux.HandleFunc(rootPath, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "security_group": {
+    "name": "test",
+    "description": "something"
+  }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group": {
+    "description": "something",
+    "id": "{groupID}",
+    "name": "test",
+    "rules": [],
+    "tenant_id": "openstack"
+  }
+}
+`)
+	})
+}
+
+func mockUpdateGroupResponse(t *testing.T, groupID string) {
+	url := fmt.Sprintf("%s/%s", rootPath, groupID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "security_group": {
+    "name": "new_name",
+		"description": "new_desc"
+  }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group": {
+    "description": "something",
+    "id": "{groupID}",
+    "name": "new_name",
+    "rules": [],
+    "tenant_id": "openstack"
+  }
+}
+`)
+	})
+}
+
+func mockGetGroupsResponse(t *testing.T, groupID string) {
+	url := fmt.Sprintf("%s/%s", rootPath, groupID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group": {
+    "description": "default",
+    "id": "{groupID}",
+    "name": "default",
+    "rules": [
+      {
+        "from_port": 80,
+        "group": {
+          "tenant_id": "openstack",
+          "name": "default"
+        },
+        "ip_protocol": "TCP",
+        "to_port": 85,
+        "parent_group_id": "{groupID}",
+        "ip_range": {
+						"cidr": "0.0.0.0"
+				},
+        "id": "{ruleID}"
+      }
+    ],
+    "tenant_id": "openstack"
+  }
+}
+			`)
+	})
+}
+
+func mockGetNumericIDGroupResponse(t *testing.T, groupID int) {
+	url := fmt.Sprintf("%s/%d", rootPath, groupID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"security_group": {
+		"id": "12345"
+	}
+}
+			`)
+	})
+}
+
+func mockDeleteGroupResponse(t *testing.T, groupID string) {
+	url := fmt.Sprintf("%s/%s", rootPath, groupID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+func mockAddRuleResponse(t *testing.T) {
+	th.Mux.HandleFunc("/os-security-group-rules", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "security_group_rule": {
+    "from_port": 22,
+    "ip_protocol": "TCP",
+    "to_port": 22,
+    "parent_group_id": "{groupID}",
+    "cidr": "0.0.0.0/0"
+  }
+}	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "security_group_rule": {
+    "from_port": 22,
+    "group": {},
+    "ip_protocol": "TCP",
+    "to_port": 22,
+    "parent_group_id": "{groupID}",
+    "ip_range": {
+      "cidr": "0.0.0.0/0"
+    },
+    "id": "{ruleID}"
+  }
+}`)
+	})
+}
+
+func mockDeleteRuleResponse(t *testing.T, ruleID string) {
+	url := fmt.Sprintf("/os-security-group-rules/%s", ruleID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+func mockAddServerToGroupResponse(t *testing.T, serverID string) {
+	url := fmt.Sprintf("/servers/%s/action", serverID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "addSecurityGroup": {
+    "name": "test"
+  }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+		fmt.Fprintf(w, `{}`)
+	})
+}
+
+func mockRemoveServerFromGroupResponse(t *testing.T, serverID string) {
+	url := fmt.Sprintf("/servers/%s/action", serverID)
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+  "removeSecurityGroup": {
+    "name": "test"
+  }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+		fmt.Fprintf(w, `{}`)
+	})
+}
diff --git a/openstack/compute/v2/extensions/secgroups/requests.go b/openstack/compute/v2/extensions/secgroups/requests.go
new file mode 100644
index 0000000..81993bd
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests.go
@@ -0,0 +1,171 @@
+package secgroups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+func commonList(client *gophercloud.ServiceClient, url string) pagination.Pager {
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return SecurityGroupPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// List will return a collection of all the security groups for a particular
+// tenant.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return commonList(client, rootURL(client))
+}
+
+// ListByServer will return a collection of all the security groups which are
+// associated with a particular server.
+func ListByServer(client *gophercloud.ServiceClient, serverID string) pagination.Pager {
+	return commonList(client, listByServerURL(client, serverID))
+}
+
+// GroupOpts is the underlying struct responsible for creating or updating
+// security groups. It therefore represents the mutable attributes of a
+// security group.
+type GroupOpts struct {
+	// the name of your security group.
+	Name string `json:"name" required:"true"`
+	// the description of your security group.
+	Description string `json:"description" required:"true"`
+}
+
+// CreateOpts is the struct responsible for creating a security group.
+type CreateOpts GroupOpts
+
+// CreateOptsBuilder builds the create options into a serializable format.
+type CreateOptsBuilder interface {
+	ToSecGroupCreateMap() (map[string]interface{}, error)
+}
+
+// ToSecGroupCreateMap builds the create options into a serializable format.
+func (opts CreateOpts) ToSecGroupCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// Create will create a new security group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToSecGroupCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// UpdateOpts is the struct responsible for updating an existing security group.
+type UpdateOpts GroupOpts
+
+// UpdateOptsBuilder builds the update options into a serializable format.
+type UpdateOptsBuilder interface {
+	ToSecGroupUpdateMap() (map[string]interface{}, error)
+}
+
+// ToSecGroupUpdateMap builds the update options into a serializable format.
+func (opts UpdateOpts) ToSecGroupUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// Update will modify the mutable properties of a security group, notably its
+// name and description.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToSecGroupUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(resourceURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get will return details for a particular security group.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete will permanently delete a security group from the project.
+func Delete(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Delete(resourceURL(client, id), nil)
+	return
+}
+
+// CreateRuleOpts represents the configuration for adding a new rule to an
+// existing security group.
+type CreateRuleOpts struct {
+	// Required - the ID of the group that this rule will be added to.
+	ParentGroupID string `json:"parent_group_id" required:"true"`
+	// Required - the lower bound of the port range that will be opened.
+	FromPort int `json:"from_port" required:"true"`
+	// Required - the upper bound of the port range that will be opened.
+	ToPort int `json:"to_port" required:"true"`
+	// Required - the protocol type that will be allowed, e.g. TCP.
+	IPProtocol string `json:"ip_protocol" required:"true"`
+	// ONLY required if FromGroupID is blank. This represents the IP range that
+	// will be the source of network traffic to your security group. Use
+	// 0.0.0.0/0 to allow all IP addresses.
+	CIDR string `json:"cidr,omitempty"`
+	// ONLY required if CIDR is blank. This value represents the ID of a group
+	// that forwards traffic to the parent group. So, instead of accepting
+	// network traffic from an entire IP range, you can instead refine the
+	// inbound source by an existing security group.
+	FromGroupID string `json:"group_id,omitempty"`
+}
+
+// CreateRuleOptsBuilder builds the create rule options into a serializable format.
+type CreateRuleOptsBuilder interface {
+	ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// ToRuleCreateMap builds the create rule options into a serializable format.
+func (opts CreateRuleOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group_rule")
+}
+
+// CreateRule will add a new rule to an existing security group (whose ID is
+// specified in CreateRuleOpts). You have the option of controlling inbound
+// traffic from either an IP range (CIDR) or from another security group.
+func CreateRule(client *gophercloud.ServiceClient, opts CreateRuleOptsBuilder) (r CreateRuleResult) {
+	b, err := opts.ToRuleCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(rootRuleURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// DeleteRule will permanently delete a rule from a security group.
+func DeleteRule(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Delete(resourceRuleURL(client, id), nil)
+	return
+}
+
+func actionMap(prefix, groupName string) map[string]map[string]string {
+	return map[string]map[string]string{
+		prefix + "SecurityGroup": map[string]string{"name": groupName},
+	}
+}
+
+// AddServer will associate a server and a security group, enforcing the
+// rules of the group on the server.
+func AddServer(client *gophercloud.ServiceClient, serverID, groupName string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Post(serverActionURL(client, serverID), actionMap("add", groupName), &r.Body, nil)
+	return
+}
+
+// RemoveServer will disassociate a server from a security group.
+func RemoveServer(client *gophercloud.ServiceClient, serverID, groupName string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Post(serverActionURL(client, serverID), actionMap("remove", groupName), &r.Body, nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/secgroups/requests_test.go b/openstack/compute/v2/extensions/secgroups/requests_test.go
new file mode 100644
index 0000000..9496d4a
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/requests_test.go
@@ -0,0 +1,248 @@
+package secgroups
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const (
+	serverID = "{serverID}"
+	groupID  = "{groupID}"
+	ruleID   = "{ruleID}"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockListGroupsResponse(t)
+
+	count := 0
+
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractSecurityGroups(page)
+		if err != nil {
+			t.Errorf("Failed to extract users: %v", err)
+			return false, err
+		}
+
+		expected := []SecurityGroup{
+			SecurityGroup{
+				ID:          groupID,
+				Description: "default",
+				Name:        "default",
+				Rules:       []Rule{},
+				TenantID:    "openstack",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestListByServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockListGroupsByServerResponse(t, serverID)
+
+	count := 0
+
+	err := ListByServer(client.ServiceClient(), serverID).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractSecurityGroups(page)
+		if err != nil {
+			t.Errorf("Failed to extract users: %v", err)
+			return false, err
+		}
+
+		expected := []SecurityGroup{
+			SecurityGroup{
+				ID:          groupID,
+				Description: "default",
+				Name:        "default",
+				Rules:       []Rule{},
+				TenantID:    "openstack",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockCreateGroupResponse(t)
+
+	opts := CreateOpts{
+		Name:        "test",
+		Description: "something",
+	}
+
+	group, err := Create(client.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &SecurityGroup{
+		ID:          groupID,
+		Name:        "test",
+		Description: "something",
+		TenantID:    "openstack",
+		Rules:       []Rule{},
+	}
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockUpdateGroupResponse(t, groupID)
+
+	opts := UpdateOpts{
+		Name:        "new_name",
+		Description: "new_desc",
+	}
+
+	group, err := Update(client.ServiceClient(), groupID, opts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &SecurityGroup{
+		ID:          groupID,
+		Name:        "new_name",
+		Description: "something",
+		TenantID:    "openstack",
+		Rules:       []Rule{},
+	}
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockGetGroupsResponse(t, groupID)
+
+	group, err := Get(client.ServiceClient(), groupID).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &SecurityGroup{
+		ID:          groupID,
+		Description: "default",
+		Name:        "default",
+		TenantID:    "openstack",
+		Rules: []Rule{
+			Rule{
+				FromPort:      80,
+				ToPort:        85,
+				IPProtocol:    "TCP",
+				IPRange:       IPRange{CIDR: "0.0.0.0"},
+				Group:         Group{TenantID: "openstack", Name: "default"},
+				ParentGroupID: groupID,
+				ID:            ruleID,
+			},
+		},
+	}
+
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestGetNumericID(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	numericGroupID := 12345
+
+	mockGetNumericIDGroupResponse(t, numericGroupID)
+
+	group, err := Get(client.ServiceClient(), "12345").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &SecurityGroup{ID: "12345"}
+	th.AssertDeepEquals(t, expected, group)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockDeleteGroupResponse(t, groupID)
+
+	err := Delete(client.ServiceClient(), groupID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAddRule(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockAddRuleResponse(t)
+
+	opts := CreateRuleOpts{
+		ParentGroupID: groupID,
+		FromPort:      22,
+		ToPort:        22,
+		IPProtocol:    "TCP",
+		CIDR:          "0.0.0.0/0",
+	}
+
+	rule, err := CreateRule(client.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Rule{
+		FromPort:      22,
+		ToPort:        22,
+		Group:         Group{},
+		IPProtocol:    "TCP",
+		ParentGroupID: groupID,
+		IPRange:       IPRange{CIDR: "0.0.0.0/0"},
+		ID:            ruleID,
+	}
+
+	th.AssertDeepEquals(t, expected, rule)
+}
+
+func TestDeleteRule(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockDeleteRuleResponse(t, ruleID)
+
+	err := DeleteRule(client.ServiceClient(), ruleID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestAddServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockAddServerToGroupResponse(t, serverID)
+
+	err := AddServer(client.ServiceClient(), serverID, "test").ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestRemoveServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockRemoveServerFromGroupResponse(t, serverID)
+
+	err := RemoveServer(client.ServiceClient(), serverID, "test").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/secgroups/results.go b/openstack/compute/v2/extensions/secgroups/results.go
new file mode 100644
index 0000000..764f580
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/results.go
@@ -0,0 +1,127 @@
+package secgroups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// SecurityGroup represents a security group.
+type SecurityGroup struct {
+	// The unique ID of the group. If Neutron is installed, this ID will be
+	// represented as a string UUID; if Neutron is not installed, it will be a
+	// numeric ID. For the sake of consistency, we always cast it to a string.
+	ID string
+
+	// The human-readable name of the group, which needs to be unique.
+	Name string
+
+	// The human-readable description of the group.
+	Description string
+
+	// The rules which determine how this security group operates.
+	Rules []Rule
+
+	// The ID of the tenant to which this security group belongs.
+	TenantID string `json:"tenant_id"`
+}
+
+// Rule represents a security group rule, a policy which determines how a
+// security group operates and what inbound traffic it allows in.
+type Rule struct {
+	// The unique ID. If Neutron is installed, this ID will be
+	// represented as a string UUID; if Neutron is not installed, it will be a
+	// numeric ID. For the sake of consistency, we always cast it to a string.
+	ID string
+
+	// The lower bound of the port range which this security group should open up
+	FromPort int `json:"from_port"`
+
+	// The upper bound of the port range which this security group should open up
+	ToPort int `json:"to_port"`
+
+	// The IP protocol (e.g. TCP) which the security group accepts
+	IPProtocol string `json:"ip_protocol"`
+
+	// The CIDR IP range whose traffic can be received
+	IPRange IPRange `json:"ip_range"`
+
+	// The security group ID to which this rule belongs
+	ParentGroupID string `json:"parent_group_id"`
+
+	// Not documented.
+	Group Group
+}
+
+// IPRange represents the IP range whose traffic will be accepted by the
+// security group.
+type IPRange struct {
+	CIDR string
+}
+
+// Group represents a group.
+type Group struct {
+	TenantID string `json:"tenant_id"`
+	Name     string
+}
+
+// SecurityGroupPage is a single page of a SecurityGroup collection.
+type SecurityGroupPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Security Groups contains any results.
+func (page SecurityGroupPage) IsEmpty() (bool, error) {
+	users, err := ExtractSecurityGroups(page)
+	return len(users) == 0, err
+}
+
+// ExtractSecurityGroups returns a slice of SecurityGroups contained in a single page of results.
+func ExtractSecurityGroups(r pagination.Page) ([]SecurityGroup, error) {
+	var s struct {
+		SecurityGroups []SecurityGroup `json:"security_groups"`
+	}
+	err := (r.(SecurityGroupPage)).ExtractInto(&s)
+	return s.SecurityGroups, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// Extract will extract a SecurityGroup struct from most responses.
+func (r commonResult) Extract() (*SecurityGroup, error) {
+	var s struct {
+		SecurityGroup *SecurityGroup `json:"security_group"`
+	}
+	err := r.ExtractInto(&s)
+	return s.SecurityGroup, err
+}
+
+// CreateRuleResult represents the result when adding rules to a security group.
+type CreateRuleResult struct {
+	gophercloud.Result
+}
+
+// Extract will extract a Rule struct from a CreateRuleResult.
+func (r CreateRuleResult) Extract() (*Rule, error) {
+	var s struct {
+		Rule *Rule `json:"security_group_rule"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Rule, err
+}
diff --git a/openstack/compute/v2/extensions/secgroups/urls.go b/openstack/compute/v2/extensions/secgroups/urls.go
new file mode 100644
index 0000000..d99746c
--- /dev/null
+++ b/openstack/compute/v2/extensions/secgroups/urls.go
@@ -0,0 +1,32 @@
+package secgroups
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	secgrouppath = "os-security-groups"
+	rulepath     = "os-security-group-rules"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(secgrouppath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(secgrouppath)
+}
+
+func listByServerURL(c *gophercloud.ServiceClient, serverID string) string {
+	return c.ServiceURL("servers", serverID, secgrouppath)
+}
+
+func rootRuleURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rulepath)
+}
+
+func resourceRuleURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rulepath, id)
+}
+
+func serverActionURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("servers", id, "action")
+}
diff --git a/openstack/compute/v2/extensions/servergroups/doc.go b/openstack/compute/v2/extensions/servergroups/doc.go
new file mode 100644
index 0000000..1e5ed56
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/doc.go
@@ -0,0 +1,2 @@
+// Package servergroups provides the ability to manage server groups
+package servergroups
diff --git a/openstack/compute/v2/extensions/servergroups/fixtures.go b/openstack/compute/v2/extensions/servergroups/fixtures.go
new file mode 100644
index 0000000..003e9aa
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/fixtures.go
@@ -0,0 +1,161 @@
+// +build fixtures
+
+package servergroups
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+    "server_groups": [
+        {
+            "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+            "name": "test",
+            "policies": [
+                "anti-affinity"
+            ],
+            "members": [],
+            "metadata": {}
+        },
+        {
+            "id": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+            "name": "test2",
+            "policies": [
+                "affinity"
+            ],
+            "members": [],
+            "metadata": {}
+        }
+    ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+    "server_group": {
+        "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+        "name": "test",
+        "policies": [
+            "anti-affinity"
+        ],
+        "members": [],
+        "metadata": {}
+    }
+}
+`
+
+// CreateOutput is a sample response to a Post call
+const CreateOutput = `
+{
+    "server_group": {
+        "id": "616fb98f-46ca-475e-917e-2563e5a8cd19",
+        "name": "test",
+        "policies": [
+            "anti-affinity"
+        ],
+        "members": [],
+        "metadata": {}
+    }
+}
+`
+
+// FirstServerGroup is the first result in ListOutput.
+var FirstServerGroup = ServerGroup{
+	ID:   "616fb98f-46ca-475e-917e-2563e5a8cd19",
+	Name: "test",
+	Policies: []string{
+		"anti-affinity",
+	},
+	Members:  []string{},
+	Metadata: map[string]interface{}{},
+}
+
+// SecondServerGroup is the second result in ListOutput.
+var SecondServerGroup = ServerGroup{
+	ID:   "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+	Name: "test2",
+	Policies: []string{
+		"affinity",
+	},
+	Members:  []string{},
+	Metadata: map[string]interface{}{},
+}
+
+// ExpectedServerGroupSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedServerGroupSlice = []ServerGroup{FirstServerGroup, SecondServerGroup}
+
+// CreatedServerGroup is the parsed result from CreateOutput.
+var CreatedServerGroup = ServerGroup{
+	ID:   "616fb98f-46ca-475e-917e-2563e5a8cd19",
+	Name: "test",
+	Policies: []string{
+		"anti-affinity",
+	},
+	Members:  []string{},
+	Metadata: map[string]interface{}{},
+}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-server-groups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing server group
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-server-groups/4d8c3732-a248-40ed-bebc-539a6ffd25c0", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new server group
+func HandleCreateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-server-groups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+    "server_group": {
+        "name": "test",
+        "policies": [
+            "anti-affinity"
+        ]
+    }
+}
+`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, CreateOutput)
+	})
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing server group
+func HandleDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-server-groups/616fb98f-46ca-475e-917e-2563e5a8cd19", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/extensions/servergroups/requests.go b/openstack/compute/v2/extensions/servergroups/requests.go
new file mode 100644
index 0000000..ee98837
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/requests.go
@@ -0,0 +1,57 @@
+package servergroups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of ServerGroups.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return ServerGroupPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notably, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	ToServerGroupCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies a Server Group allocation request
+type CreateOpts struct {
+	// Name is the name of the server group
+	Name string `json:"name" required:"true"`
+	// Policies are the server group policies
+	Policies []string `json:"policies" required:"true"`
+}
+
+// ToServerGroupCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToServerGroupCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "server_group")
+}
+
+// Create requests the creation of a new Server Group
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToServerGroupCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get returns data about a previously created ServerGroup.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete requests the deletion of a previously allocated ServerGroup.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/servergroups/requests_test.go b/openstack/compute/v2/extensions/servergroups/requests_test.go
new file mode 100644
index 0000000..458526a
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/requests_test.go
@@ -0,0 +1,59 @@
+package servergroups
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractServerGroups(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedServerGroupSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateSuccessfully(t)
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Name:     "test",
+		Policies: []string{"anti-affinity"},
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &CreatedServerGroup, actual)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "4d8c3732-a248-40ed-bebc-539a6ffd25c0").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &FirstServerGroup, actual)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteSuccessfully(t)
+
+	err := Delete(client.ServiceClient(), "616fb98f-46ca-475e-917e-2563e5a8cd19").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/servergroups/results.go b/openstack/compute/v2/extensions/servergroups/results.go
new file mode 100644
index 0000000..ab49b35
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/results.go
@@ -0,0 +1,78 @@
+package servergroups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// A ServerGroup creates a policy for instance placement in the cloud
+type ServerGroup struct {
+	// ID is the unique ID of the Server Group.
+	ID string `json:"id"`
+
+	// Name is the common name of the server group.
+	Name string `json:"name"`
+
+	// Polices are the group policies.
+	Policies []string `json:"policies"`
+
+	// Members are the members of the server group.
+	Members []string `json:"members"`
+
+	// Metadata includes a list of all user-specified key-value pairs attached to the Server Group.
+	Metadata map[string]interface{}
+}
+
+// ServerGroupPage stores a single, only page of ServerGroups
+// results from a List call.
+type ServerGroupPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a ServerGroupsPage is empty.
+func (page ServerGroupPage) IsEmpty() (bool, error) {
+	va, err := ExtractServerGroups(page)
+	return len(va) == 0, err
+}
+
+// ExtractServerGroups interprets a page of results as a slice of
+// ServerGroups.
+func ExtractServerGroups(r pagination.Page) ([]ServerGroup, error) {
+	var s struct {
+		ServerGroups []ServerGroup `json:"server_groups"`
+	}
+	err := (r.(ServerGroupPage)).ExtractInto(&s)
+	return s.ServerGroups, err
+}
+
+type ServerGroupResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Server Group resource
+// response as a ServerGroup struct.
+func (r ServerGroupResult) Extract() (*ServerGroup, error) {
+	var s struct {
+		ServerGroup *ServerGroup `json:"server_group"`
+	}
+	err := r.ExtractInto(&s)
+	return s.ServerGroup, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a ServerGroup.
+type CreateResult struct {
+	ServerGroupResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a ServerGroup.
+type GetResult struct {
+	ServerGroupResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/servergroups/urls.go b/openstack/compute/v2/extensions/servergroups/urls.go
new file mode 100644
index 0000000..9a1f99b
--- /dev/null
+++ b/openstack/compute/v2/extensions/servergroups/urls.go
@@ -0,0 +1,25 @@
+package servergroups
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-server-groups"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return getURL(c, id)
+}
diff --git a/openstack/compute/v2/extensions/startstop/doc.go b/openstack/compute/v2/extensions/startstop/doc.go
new file mode 100644
index 0000000..d2729f8
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/doc.go
@@ -0,0 +1,5 @@
+/*
+Package startstop provides functionality to start and stop servers that have
+been provisioned by the OpenStack Compute service.
+*/
+package startstop
diff --git a/openstack/compute/v2/extensions/startstop/fixtures.go b/openstack/compute/v2/extensions/startstop/fixtures.go
new file mode 100644
index 0000000..7169f7f
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/fixtures.go
@@ -0,0 +1,27 @@
+package startstop
+
+import (
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func mockStartServerResponse(t *testing.T, id string) {
+	th.Mux.HandleFunc("/servers/"+id+"/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{"os-start": null}`)
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+func mockStopServerResponse(t *testing.T, id string) {
+	th.Mux.HandleFunc("/servers/"+id+"/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{"os-stop": null}`)
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/extensions/startstop/requests.go b/openstack/compute/v2/extensions/startstop/requests.go
new file mode 100644
index 0000000..1d8a593
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/requests.go
@@ -0,0 +1,19 @@
+package startstop
+
+import "github.com/gophercloud/gophercloud"
+
+func actionURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("servers", id, "action")
+}
+
+// Start is the operation responsible for starting a Compute server.
+func Start(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"os-start": nil}, nil, nil)
+	return
+}
+
+// Stop is the operation responsible for stopping a Compute server.
+func Stop(client *gophercloud.ServiceClient, id string) (r gophercloud.ErrResult) {
+	_, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"os-stop": nil}, nil, nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/startstop/requests_test.go b/openstack/compute/v2/extensions/startstop/requests_test.go
new file mode 100644
index 0000000..c7e26ae
--- /dev/null
+++ b/openstack/compute/v2/extensions/startstop/requests_test.go
@@ -0,0 +1,30 @@
+package startstop
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const serverID = "{serverId}"
+
+func TestStart(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockStartServerResponse(t, serverID)
+
+	err := Start(client.ServiceClient(), serverID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestStop(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockStopServerResponse(t, serverID)
+
+	err := Stop(client.ServiceClient(), serverID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/doc.go b/openstack/compute/v2/extensions/tenantnetworks/doc.go
new file mode 100644
index 0000000..65c46ff
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/doc.go
@@ -0,0 +1,2 @@
+// Package tenantnetworks provides the ability for tenants to see information about the networks they have access to
+package tenantnetworks
diff --git a/openstack/compute/v2/extensions/tenantnetworks/fixtures.go b/openstack/compute/v2/extensions/tenantnetworks/fixtures.go
new file mode 100644
index 0000000..52fa013
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/fixtures.go
@@ -0,0 +1,84 @@
+// +build fixtures
+
+package tenantnetworks
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+    "networks": [
+        {
+            "cidr": "10.0.0.0/29",
+            "id": "20c8acc0-f747-4d71-a389-46d078ebf047",
+            "label": "mynet_0"
+        },
+        {
+            "cidr": "10.0.0.10/29",
+            "id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+            "label": "mynet_1"
+        }
+    ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+    "network": {
+			"cidr": "10.0.0.10/29",
+			"id": "20c8acc0-f747-4d71-a389-46d078ebf000",
+			"label": "mynet_1"
+		}
+}
+`
+
+// FirstNetwork is the first result in ListOutput.
+var nilTime time.Time
+var FirstNetwork = Network{
+	CIDR: "10.0.0.0/29",
+	ID:   "20c8acc0-f747-4d71-a389-46d078ebf047",
+	Name: "mynet_0",
+}
+
+// SecondNetwork is the second result in ListOutput.
+var SecondNetwork = Network{
+	CIDR: "10.0.0.10/29",
+	ID:   "20c8acc0-f747-4d71-a389-46d078ebf000",
+	Name: "mynet_1",
+}
+
+// ExpectedNetworkSlice is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedNetworkSlice = []Network{FirstNetwork, SecondNetwork}
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-tenant-networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing network.
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/os-tenant-networks/20c8acc0-f747-4d71-a389-46d078ebf000", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/requests.go b/openstack/compute/v2/extensions/tenantnetworks/requests.go
new file mode 100644
index 0000000..82836d4
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/requests.go
@@ -0,0 +1,19 @@
+package tenantnetworks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of Network.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return NetworkPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get returns data about a previously created Network.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/requests_test.go b/openstack/compute/v2/extensions/tenantnetworks/requests_test.go
new file mode 100644
index 0000000..e8726c4
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/requests_test.go
@@ -0,0 +1,37 @@
+package tenantnetworks
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNetworks(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedNetworkSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "20c8acc0-f747-4d71-a389-46d078ebf000").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &SecondNetwork, actual)
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/results.go b/openstack/compute/v2/extensions/tenantnetworks/results.go
new file mode 100644
index 0000000..88cbc80
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/results.go
@@ -0,0 +1,59 @@
+package tenantnetworks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// A Network represents a nova-network that an instance communicates on
+type Network struct {
+	// CIDR is the IPv4 subnet.
+	CIDR string `json:"cidr"`
+
+	// ID is the UUID of the network.
+	ID string `json:"id"`
+
+	// Name is the common name that the network has.
+	Name string `json:"label"`
+}
+
+// NetworkPage stores a single, only page of Networks
+// results from a List call.
+type NetworkPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a NetworkPage is empty.
+func (page NetworkPage) IsEmpty() (bool, error) {
+	va, err := ExtractNetworks(page)
+	return len(va) == 0, err
+}
+
+// ExtractNetworks interprets a page of results as a slice of Networks
+func ExtractNetworks(r pagination.Page) ([]Network, error) {
+	var s struct {
+		Networks []Network `json:"networks"`
+	}
+	err := (r.(NetworkPage)).ExtractInto(&s)
+	return s.Networks, err
+}
+
+type NetworkResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any Network resource
+// response as a Network struct.
+func (r NetworkResult) Extract() (*Network, error) {
+	var s struct {
+		Network *Network `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a Network.
+type GetResult struct {
+	NetworkResult
+}
diff --git a/openstack/compute/v2/extensions/tenantnetworks/urls.go b/openstack/compute/v2/extensions/tenantnetworks/urls.go
new file mode 100644
index 0000000..683041d
--- /dev/null
+++ b/openstack/compute/v2/extensions/tenantnetworks/urls.go
@@ -0,0 +1,17 @@
+package tenantnetworks
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-tenant-networks"
+
+func resourceURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return resourceURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/doc.go b/openstack/compute/v2/extensions/volumeattach/doc.go
new file mode 100644
index 0000000..22f68d8
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/doc.go
@@ -0,0 +1,3 @@
+// Package volumeattach provides the ability to attach and detach volumes
+// to instances
+package volumeattach
diff --git a/openstack/compute/v2/extensions/volumeattach/requests.go b/openstack/compute/v2/extensions/volumeattach/requests.go
new file mode 100644
index 0000000..ee4d62d
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/requests.go
@@ -0,0 +1,57 @@
+package volumeattach
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List returns a Pager that allows you to iterate over a collection of VolumeAttachments.
+func List(client *gophercloud.ServiceClient, serverID string) pagination.Pager {
+	return pagination.NewPager(client, listURL(client, serverID), func(r pagination.PageResult) pagination.Page {
+		return VolumeAttachmentPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call. Notable, the
+// CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	ToVolumeAttachmentCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts specifies volume attachment creation or import parameters.
+type CreateOpts struct {
+	// Device is the device that the volume will attach to the instance as. Omit for "auto"
+	Device string `json:"device,omitempty"`
+	// VolumeID is the ID of the volume to attach to the instance
+	VolumeID string `json:"volumeId" required:"true"`
+}
+
+// ToVolumeAttachmentCreateMap constructs a request body from CreateOpts.
+func (opts CreateOpts) ToVolumeAttachmentCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "volumeAttachment")
+}
+
+// Create requests the creation of a new volume attachment on the server
+func Create(client *gophercloud.ServiceClient, serverID string, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToVolumeAttachmentCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(createURL(client, serverID), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Get returns public data about a previously created VolumeAttachment.
+func Get(client *gophercloud.ServiceClient, serverID, attachmentID string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, serverID, attachmentID), &r.Body, nil)
+	return
+}
+
+// Delete requests the deletion of a previous stored VolumeAttachment from the server.
+func Delete(client *gophercloud.ServiceClient, serverID, attachmentID string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, serverID, attachmentID), nil)
+	return
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/requests_test.go b/openstack/compute/v2/extensions/volumeattach/requests_test.go
new file mode 100644
index 0000000..3257f39
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/requests_test.go
@@ -0,0 +1,94 @@
+package volumeattach
+
+import (
+	"testing"
+
+	fixtures "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/volumeattach/testing"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// FirstVolumeAttachment is the first result in ListOutput.
+var FirstVolumeAttachment = VolumeAttachment{
+	Device:   "/dev/vdd",
+	ID:       "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+	ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+	VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+}
+
+// SecondVolumeAttachment is the first result in ListOutput.
+var SecondVolumeAttachment = VolumeAttachment{
+	Device:   "/dev/vdc",
+	ID:       "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+	ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+	VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+// ExpectedVolumeAttachmentSlide is the slice of results that should be parsed
+// from ListOutput, in the expected order.
+var ExpectedVolumeAttachmentSlice = []VolumeAttachment{FirstVolumeAttachment, SecondVolumeAttachment}
+
+//CreatedVolumeAttachment is the parsed result from CreatedOutput.
+var CreatedVolumeAttachment = VolumeAttachment{
+	Device:   "/dev/vdc",
+	ID:       "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+	ServerID: "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+	VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixtures.HandleListSuccessfully(t)
+	serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+	count := 0
+	err := List(client.ServiceClient(), serverID).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVolumeAttachments(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, ExpectedVolumeAttachmentSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixtures.HandleCreateSuccessfully(t)
+	serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+	actual, err := Create(client.ServiceClient(), serverID, CreateOpts{
+		Device:   "/dev/vdc",
+		VolumeID: "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &CreatedVolumeAttachment, actual)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixtures.HandleGetSuccessfully(t)
+	aID := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+	serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+	actual, err := Get(client.ServiceClient(), serverID, aID).Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, &SecondVolumeAttachment, actual)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixtures.HandleDeleteSuccessfully(t)
+	aID := "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+	serverID := "4d8c3732-a248-40ed-bebc-539a6ffd25c0"
+
+	err := Delete(client.ServiceClient(), serverID, aID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/results.go b/openstack/compute/v2/extensions/volumeattach/results.go
new file mode 100644
index 0000000..53faf5d
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/results.go
@@ -0,0 +1,76 @@
+package volumeattach
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// VolumeAttachment controls the attachment of a volume to an instance.
+type VolumeAttachment struct {
+	// ID is a unique id of the attachment
+	ID string `json:"id"`
+
+	// Device is what device the volume is attached as
+	Device string `json:"device"`
+
+	// VolumeID is the ID of the attached volume
+	VolumeID string `json:"volumeId"`
+
+	// ServerID is the ID of the instance that has the volume attached
+	ServerID string `json:"serverId"`
+}
+
+// VolumeAttachmentPage stores a single, only page of VolumeAttachments
+// results from a List call.
+type VolumeAttachmentPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a VolumeAttachmentsPage is empty.
+func (page VolumeAttachmentPage) IsEmpty() (bool, error) {
+	va, err := ExtractVolumeAttachments(page)
+	return len(va) == 0, err
+}
+
+// ExtractVolumeAttachments interprets a page of results as a slice of
+// VolumeAttachments.
+func ExtractVolumeAttachments(r pagination.Page) ([]VolumeAttachment, error) {
+	var s struct {
+		VolumeAttachments []VolumeAttachment `json:"volumeAttachments"`
+	}
+	err := (r.(VolumeAttachmentPage)).ExtractInto(&s)
+	return s.VolumeAttachments, err
+}
+
+// VolumeAttachmentResult is the result from a volume attachment operation.
+type VolumeAttachmentResult struct {
+	gophercloud.Result
+}
+
+// Extract is a method that attempts to interpret any VolumeAttachment resource
+// response as a VolumeAttachment struct.
+func (r VolumeAttachmentResult) Extract() (*VolumeAttachment, error) {
+	var s struct {
+		VolumeAttachment *VolumeAttachment `json:"volumeAttachment"`
+	}
+	err := r.ExtractInto(&s)
+	return s.VolumeAttachment, err
+}
+
+// CreateResult is the response from a Create operation. Call its Extract method to interpret it
+// as a VolumeAttachment.
+type CreateResult struct {
+	VolumeAttachmentResult
+}
+
+// GetResult is the response from a Get operation. Call its Extract method to interpret it
+// as a VolumeAttachment.
+type GetResult struct {
+	VolumeAttachmentResult
+}
+
+// DeleteResult is the response from a Delete operation. Call its Extract method to determine if
+// the call succeeded or failed.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/testing/doc.go b/openstack/compute/v2/extensions/volumeattach/testing/doc.go
new file mode 100644
index 0000000..29faced
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/doc.go
@@ -0,0 +1,7 @@
+/*
+Package testing holds fixtures (which imports testing),
+so that importing volumeattach package does not inadvertently import testing into production code
+More information here:
+https://github.com/gophercloud/gophercloud/issues/473
+*/
+package testing
diff --git a/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go b/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go
new file mode 100644
index 0000000..f662852
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/testing/fixtures.go
@@ -0,0 +1,110 @@
+// +build fixtures
+
+package testing
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput is a sample response to a List call.
+const ListOutput = `
+{
+  "volumeAttachments": [
+    {
+      "device": "/dev/vdd",
+      "id": "a26887c6-c47b-4654-abb5-dfadf7d3f803",
+      "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+      "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f803"
+    },
+    {
+      "device": "/dev/vdc",
+      "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+      "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+      "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+    }
+  ]
+}
+`
+
+// GetOutput is a sample response to a Get call.
+const GetOutput = `
+{
+  "volumeAttachment": {
+    "device": "/dev/vdc",
+    "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+    "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+    "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+  }
+}
+`
+
+// CreateOutput is a sample response to a Create call.
+const CreateOutput = `
+{
+  "volumeAttachment": {
+    "device": "/dev/vdc",
+    "id": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+    "serverId": "4d8c3732-a248-40ed-bebc-539a6ffd25c0",
+    "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804"
+  }
+}
+`
+
+// HandleListSuccessfully configures the test server to respond to a List request.
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, ListOutput)
+	})
+}
+
+// HandleGetSuccessfully configures the test server to respond to a Get request
+// for an existing attachment
+func HandleGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments/a26887c6-c47b-4654-abb5-dfadf7d3f804", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, GetOutput)
+	})
+}
+
+// HandleCreateSuccessfully configures the test server to respond to a Create request
+// for a new attachment
+func HandleCreateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+{
+  "volumeAttachment": {
+    "volumeId": "a26887c6-c47b-4654-abb5-dfadf7d3f804",
+    "device": "/dev/vdc"
+  }
+}
+`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, CreateOutput)
+	})
+}
+
+// HandleDeleteSuccessfully configures the test server to respond to a Delete request for a
+// an existing attachment
+func HandleDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/4d8c3732-a248-40ed-bebc-539a6ffd25c0/os-volume_attachments/a26887c6-c47b-4654-abb5-dfadf7d3f804", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/extensions/volumeattach/urls.go b/openstack/compute/v2/extensions/volumeattach/urls.go
new file mode 100644
index 0000000..083f8dc
--- /dev/null
+++ b/openstack/compute/v2/extensions/volumeattach/urls.go
@@ -0,0 +1,25 @@
+package volumeattach
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "os-volume_attachments"
+
+func resourceURL(c *gophercloud.ServiceClient, serverID string) string {
+	return c.ServiceURL("servers", serverID, resourcePath)
+}
+
+func listURL(c *gophercloud.ServiceClient, serverID string) string {
+	return resourceURL(c, serverID)
+}
+
+func createURL(c *gophercloud.ServiceClient, serverID string) string {
+	return resourceURL(c, serverID)
+}
+
+func getURL(c *gophercloud.ServiceClient, serverID, aID string) string {
+	return c.ServiceURL("servers", serverID, resourcePath, aID)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, serverID, aID string) string {
+	return getURL(c, serverID, aID)
+}
diff --git a/openstack/compute/v2/flavors/doc.go b/openstack/compute/v2/flavors/doc.go
new file mode 100644
index 0000000..5822e1b
--- /dev/null
+++ b/openstack/compute/v2/flavors/doc.go
@@ -0,0 +1,7 @@
+// Package flavors provides information and interaction with the flavor API
+// resource in the OpenStack Compute service.
+//
+// A flavor is an available hardware configuration for a server. Each flavor
+// has a unique combination of disk space, memory capacity and priority for CPU
+// time.
+package flavors
diff --git a/openstack/compute/v2/flavors/requests.go b/openstack/compute/v2/flavors/requests.go
new file mode 100644
index 0000000..ef133ff
--- /dev/null
+++ b/openstack/compute/v2/flavors/requests.go
@@ -0,0 +1,100 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToFlavorListQuery() (string, error)
+}
+
+// ListOpts helps control the results returned by the List() function.
+// For example, a flavor with a minDisk field of 10 will not be returned if you specify MinDisk set to 20.
+// Typically, software will use the last ID of the previous call to List to set the Marker for the current call.
+type ListOpts struct {
+
+	// ChangesSince, if provided, instructs List to return only those things which have changed since the timestamp provided.
+	ChangesSince string `q:"changes-since"`
+
+	// MinDisk and MinRAM, if provided, elides flavors which do not meet your criteria.
+	MinDisk int `q:"minDisk"`
+	MinRAM  int `q:"minRam"`
+
+	// Marker and Limit control paging.
+	// Marker instructs List where to start listing from.
+	Marker string `q:"marker"`
+
+	// Limit instructs List to refrain from sending excessively large lists of flavors.
+	Limit int `q:"limit"`
+}
+
+// ToFlavorListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToFlavorListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// ListDetail instructs OpenStack to provide a list of flavors.
+// You may provide criteria by which List curtails its results for easier processing.
+// See ListOpts for more details.
+func ListDetail(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client)
+	if opts != nil {
+		query, err := opts.ToFlavorListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get instructs OpenStack to provide details on a single flavor, identified by its ID.
+// Use ExtractFlavor to convert its result into a Flavor.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// IDFromName is a convienience function that returns a flavor's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	allPages, err := ListDetail(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractFlavors(allPages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, f := range all {
+		if f.Name == name {
+			count++
+			id = f.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		err := &gophercloud.ErrResourceNotFound{}
+		err.ResourceType = "flavor"
+		err.Name = name
+		return "", err
+	case 1:
+		return id, nil
+	default:
+		err := &gophercloud.ErrMultipleResourcesFound{}
+		err.ResourceType = "flavor"
+		err.Name = name
+		err.Count = count
+		return "", err
+	}
+}
diff --git a/openstack/compute/v2/flavors/requests_test.go b/openstack/compute/v2/flavors/requests_test.go
new file mode 100644
index 0000000..311dbf0
--- /dev/null
+++ b/openstack/compute/v2/flavors/requests_test.go
@@ -0,0 +1,129 @@
+package flavors
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const tokenID = "blerb"
+
+func TestListFlavors(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/flavors/detail", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, `
+					{
+						"flavors": [
+							{
+								"id": "1",
+								"name": "m1.tiny",
+								"disk": 1,
+								"ram": 512,
+								"vcpus": 1
+							},
+							{
+								"id": "2",
+								"name": "m2.small",
+								"disk": 10,
+								"ram": 1024,
+								"vcpus": 2
+							}
+						],
+						"flavors_links": [
+							{
+								"href": "%s/flavors/detail?marker=2",
+								"rel": "next"
+							}
+						]
+					}
+				`, th.Server.URL)
+		case "2":
+			fmt.Fprintf(w, `{ "flavors": [] }`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+
+	pages := 0
+	err := ListDetail(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractFlavors(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Flavor{
+			Flavor{ID: "1", Name: "m1.tiny", Disk: 1, RAM: 512, VCPUs: 1},
+			Flavor{ID: "2", Name: "m2.small", Disk: 10, RAM: 1024, VCPUs: 2},
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %#v, but was %#v", expected, actual)
+		}
+
+		return true, nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if pages != 1 {
+		t.Errorf("Expected one page, got %d", pages)
+	}
+}
+
+func TestGetFlavor(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/flavors/12345", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"flavor": {
+					"id": "1",
+					"name": "m1.tiny",
+					"disk": 1,
+					"ram": 512,
+					"vcpus": 1,
+					"rxtx_factor": 1
+				}
+			}
+		`)
+	})
+
+	actual, err := Get(fake.ServiceClient(), "12345").Extract()
+	if err != nil {
+		t.Fatalf("Unable to get flavor: %v", err)
+	}
+
+	expected := &Flavor{
+		ID:         "1",
+		Name:       "m1.tiny",
+		Disk:       1,
+		RAM:        512,
+		VCPUs:      1,
+		RxTxFactor: 1,
+	}
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %#v, but was %#v", expected, actual)
+	}
+}
diff --git a/openstack/compute/v2/flavors/results.go b/openstack/compute/v2/flavors/results.go
new file mode 100644
index 0000000..0edc7d2
--- /dev/null
+++ b/openstack/compute/v2/flavors/results.go
@@ -0,0 +1,80 @@
+package flavors
+
+import (
+	"errors"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ErrCannotInterpret is returned by an Extract call if the response body doesn't have the expected structure.
+var ErrCannotInterpet = errors.New("Unable to interpret a response body.")
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract provides access to the individual Flavor returned by the Get function.
+func (r GetResult) Extract() (*Flavor, error) {
+	var s struct {
+		Flavor *Flavor `json:"flavor"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Flavor, err
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+	// The Id field contains the flavor's unique identifier.
+	// For example, this identifier will be useful when specifying which hardware configuration to use for a new server instance.
+	ID string `json:"id"`
+
+	// The Disk and RA< fields provide a measure of storage space offered by the flavor, in GB and MB, respectively.
+	Disk int `json:"disk"`
+	RAM  int `json:"ram"`
+
+	// The Name field provides a human-readable moniker for the flavor.
+	Name string `json:"name"`
+
+	RxTxFactor float64 `json:"rxtx_factor"`
+
+	// Swap indicates how much space is reserved for swap.
+	// If not provided, this field will be set to 0.
+	Swap int `json:"swap"`
+
+	// VCPUs indicates how many (virtual) CPUs are available for this flavor.
+	VCPUs int `json:"vcpus"`
+}
+
+// FlavorPage contains a single page of the response from a List call.
+type FlavorPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty determines if a page contains any results.
+func (page FlavorPage) IsEmpty() (bool, error) {
+	flavors, err := ExtractFlavors(page)
+	return len(flavors) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page FlavorPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"flavors_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+	var s struct {
+		Flavors []Flavor `json:"flavors"`
+	}
+	err := (r.(FlavorPage)).ExtractInto(&s)
+	return s.Flavors, err
+}
diff --git a/openstack/compute/v2/flavors/urls.go b/openstack/compute/v2/flavors/urls.go
new file mode 100644
index 0000000..ee0dfdb
--- /dev/null
+++ b/openstack/compute/v2/flavors/urls.go
@@ -0,0 +1,13 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+)
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("flavors", id)
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("flavors", "detail")
+}
diff --git a/openstack/compute/v2/images/doc.go b/openstack/compute/v2/images/doc.go
new file mode 100644
index 0000000..0edaa3f
--- /dev/null
+++ b/openstack/compute/v2/images/doc.go
@@ -0,0 +1,7 @@
+// Package images provides information and interaction with the image API
+// resource in the OpenStack Compute service.
+//
+// An image is a collection of files used to create or rebuild a server.
+// Operators provide a number of pre-built OS images by default. You may also
+// create custom images from cloud servers you have launched.
+package images
diff --git a/openstack/compute/v2/images/requests.go b/openstack/compute/v2/images/requests.go
new file mode 100644
index 0000000..df9f1da
--- /dev/null
+++ b/openstack/compute/v2/images/requests.go
@@ -0,0 +1,102 @@
+package images
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToImageListQuery() (string, error)
+}
+
+// ListOpts contain options for limiting the number of Images returned from a call to ListDetail.
+type ListOpts struct {
+	// When the image last changed status (in date-time format).
+	ChangesSince string `q:"changes-since"`
+	// The number of Images to return.
+	Limit int `q:"limit"`
+	// UUID of the Image at which to set a marker.
+	Marker string `q:"marker"`
+	// The name of the Image.
+	Name string `q:"name"`
+	// The name of the Server (in URL format).
+	Server string `q:"server"`
+	// The current status of the Image.
+	Status string `q:"status"`
+	// The value of the type of image (e.g. BASE, SERVER, ALL)
+	Type string `q:"type"`
+}
+
+// ToImageListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToImageListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// ListDetail enumerates the available images.
+func ListDetail(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listDetailURL(client)
+	if opts != nil {
+		query, err := opts.ToImageListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get acquires additional detail about a specific image by ID.
+// Use ExtractImage() to interpret the result as an openstack Image.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete deletes the specified image ID.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// IDFromName is a convienience function that returns an image's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	allPages, err := ListDetail(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractImages(allPages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, f := range all {
+		if f.Name == name {
+			count++
+			id = f.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		err := &gophercloud.ErrResourceNotFound{}
+		err.ResourceType = "image"
+		err.Name = name
+		return "", err
+	case 1:
+		return id, nil
+	default:
+		err := &gophercloud.ErrMultipleResourcesFound{}
+		err.ResourceType = "image"
+		err.Name = name
+		err.Count = count
+		return "", err
+	}
+}
diff --git a/openstack/compute/v2/images/requests_test.go b/openstack/compute/v2/images/requests_test.go
new file mode 100644
index 0000000..4b94ba1
--- /dev/null
+++ b/openstack/compute/v2/images/requests_test.go
@@ -0,0 +1,191 @@
+package images
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListImages(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/images/detail", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, `
+				{
+					"images": [
+						{
+							"status": "ACTIVE",
+							"updated": "2014-09-23T12:54:56Z",
+							"id": "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+							"OS-EXT-IMG-SIZE:size": 476704768,
+							"name": "F17-x86_64-cfntools",
+							"created": "2014-09-23T12:54:52Z",
+							"minDisk": 0,
+							"progress": 100,
+							"minRam": 0,
+							"metadata": {}
+						},
+						{
+							"status": "ACTIVE",
+							"updated": "2014-09-23T12:51:43Z",
+							"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+							"OS-EXT-IMG-SIZE:size": 13167616,
+							"name": "cirros-0.3.2-x86_64-disk",
+							"created": "2014-09-23T12:51:42Z",
+							"minDisk": 0,
+							"progress": 100,
+							"minRam": 0,
+							"metadata": {}
+						}
+					]
+				}
+			`)
+		case "2":
+			fmt.Fprintf(w, `{ "images": [] }`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+
+	pages := 0
+	options := &ListOpts{Limit: 2}
+	err := ListDetail(fake.ServiceClient(), options).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractImages(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Image{
+			Image{
+				ID:       "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+				Name:     "F17-x86_64-cfntools",
+				Created:  "2014-09-23T12:54:52Z",
+				Updated:  "2014-09-23T12:54:56Z",
+				MinDisk:  0,
+				MinRAM:   0,
+				Progress: 100,
+				Status:   "ACTIVE",
+			},
+			Image{
+				ID:       "f90f6034-2570-4974-8351-6b49732ef2eb",
+				Name:     "cirros-0.3.2-x86_64-disk",
+				Created:  "2014-09-23T12:51:42Z",
+				Updated:  "2014-09-23T12:51:43Z",
+				MinDisk:  0,
+				MinRAM:   0,
+				Progress: 100,
+				Status:   "ACTIVE",
+			},
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Unexpected page contents: expected %#v, got %#v", expected, actual)
+		}
+
+		return false, nil
+	})
+
+	if err != nil {
+		t.Fatalf("EachPage error: %v", err)
+	}
+	if pages != 1 {
+		t.Errorf("Expected one page, got %d", pages)
+	}
+}
+
+func TestGetImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/images/12345678", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"image": {
+					"status": "ACTIVE",
+					"updated": "2014-09-23T12:54:56Z",
+					"id": "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+					"OS-EXT-IMG-SIZE:size": 476704768,
+					"name": "F17-x86_64-cfntools",
+					"created": "2014-09-23T12:54:52Z",
+					"minDisk": 0,
+					"progress": 100,
+					"minRam": 0,
+					"metadata": {}
+				}
+			}
+		`)
+	})
+
+	actual, err := Get(fake.ServiceClient(), "12345678").Extract()
+	if err != nil {
+		t.Fatalf("Unexpected error from Get: %v", err)
+	}
+
+	expected := &Image{
+		Status:   "ACTIVE",
+		Updated:  "2014-09-23T12:54:56Z",
+		ID:       "f3e4a95d-1f4f-4989-97ce-f3a1fb8c04d7",
+		Name:     "F17-x86_64-cfntools",
+		Created:  "2014-09-23T12:54:52Z",
+		MinDisk:  0,
+		Progress: 100,
+		MinRAM:   0,
+	}
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %#v, but got %#v", expected, actual)
+	}
+}
+
+func TestNextPageURL(t *testing.T) {
+	var page ImagePage
+	var body map[string]interface{}
+	bodyString := []byte(`{"images":{"links":[{"href":"http://192.154.23.87/12345/images/image3","rel":"bookmark"}]}, "images_links":[{"href":"http://192.154.23.87/12345/images/image4","rel":"next"}]}`)
+	err := json.Unmarshal(bodyString, &body)
+	if err != nil {
+		t.Fatalf("Error unmarshaling data into page body: %v", err)
+	}
+	page.Body = body
+
+	expected := "http://192.154.23.87/12345/images/image4"
+	actual, err := page.NextPageURL()
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, expected, actual)
+}
+
+// Test Image delete
+func TestDeleteImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/images/12345678", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "12345678")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/compute/v2/images/results.go b/openstack/compute/v2/images/results.go
new file mode 100644
index 0000000..f38466b
--- /dev/null
+++ b/openstack/compute/v2/images/results.go
@@ -0,0 +1,81 @@
+package images
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// GetResult temporarily stores a Get response.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// DeleteResult represents the result of an image.Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Extract interprets a GetResult as an Image.
+func (r GetResult) Extract() (*Image, error) {
+	var s struct {
+		Image *Image `json:"image"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Image, err
+}
+
+// Image is used for JSON (un)marshalling.
+// It provides a description of an OS image.
+type Image struct {
+	// ID contains the image's unique identifier.
+	ID string
+
+	Created string
+
+	// MinDisk and MinRAM specify the minimum resources a server must provide to be able to install the image.
+	MinDisk int
+	MinRAM  int
+
+	// Name provides a human-readable moniker for the OS image.
+	Name string
+
+	// The Progress and Status fields indicate image-creation status.
+	// Any usable image will have 100% progress.
+	Progress int
+	Status   string
+
+	Updated string
+}
+
+// ImagePage contains a single page of results from a List operation.
+// Use ExtractImages to convert it into a slice of usable structs.
+type ImagePage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Image results.
+func (page ImagePage) IsEmpty() (bool, error) {
+	images, err := ExtractImages(page)
+	return len(images) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page ImagePage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"images_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractImages converts a page of List results into a slice of usable Image structs.
+func ExtractImages(r pagination.Page) ([]Image, error) {
+	var s struct {
+		Images []Image `json:"images"`
+	}
+	err := (r.(ImagePage)).ExtractInto(&s)
+	return s.Images, err
+}
diff --git a/openstack/compute/v2/images/urls.go b/openstack/compute/v2/images/urls.go
new file mode 100644
index 0000000..57787fb
--- /dev/null
+++ b/openstack/compute/v2/images/urls.go
@@ -0,0 +1,15 @@
+package images
+
+import "github.com/gophercloud/gophercloud"
+
+func listDetailURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("images", "detail")
+}
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("images", id)
+}
+
+func deleteURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("images", id)
+}
diff --git a/openstack/compute/v2/servers/doc.go b/openstack/compute/v2/servers/doc.go
new file mode 100644
index 0000000..fe45671
--- /dev/null
+++ b/openstack/compute/v2/servers/doc.go
@@ -0,0 +1,6 @@
+// Package servers provides information and interaction with the server API
+// resource in the OpenStack Compute service.
+//
+// A server is a virtual machine instance in the compute system. In order for
+// one to be provisioned, a valid flavor and image are required.
+package servers
diff --git a/openstack/compute/v2/servers/errors.go b/openstack/compute/v2/servers/errors.go
new file mode 100644
index 0000000..c9f0e3c
--- /dev/null
+++ b/openstack/compute/v2/servers/errors.go
@@ -0,0 +1,71 @@
+package servers
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// ErrNeitherImageIDNorImageNameProvided is the error when neither the image
+// ID nor the image name is provided for a server operation
+type ErrNeitherImageIDNorImageNameProvided struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNeitherImageIDNorImageNameProvided) Error() string {
+	return "One and only one of the image ID and the image name must be provided."
+}
+
+// ErrNeitherFlavorIDNorFlavorNameProvided is the error when neither the flavor
+// ID nor the flavor name is provided for a server operation
+type ErrNeitherFlavorIDNorFlavorNameProvided struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNeitherFlavorIDNorFlavorNameProvided) Error() string {
+	return "One and only one of the flavor ID and the flavor name must be provided."
+}
+
+type ErrNoClientProvidedForIDByName struct{ gophercloud.ErrMissingInput }
+
+func (e ErrNoClientProvidedForIDByName) Error() string {
+	return "A service client must be provided to find a resource ID by name."
+}
+
+// ErrInvalidHowParameterProvided is the error when an unknown value is given
+// for the `how` argument
+type ErrInvalidHowParameterProvided struct{ gophercloud.ErrInvalidInput }
+
+// ErrNoAdminPassProvided is the error when an administrative password isn't
+// provided for a server operation
+type ErrNoAdminPassProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrNoImageIDProvided is the error when an image ID isn't provided for a server
+// operation
+type ErrNoImageIDProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrNoIDProvided is the error when a server ID isn't provided for a server
+// operation
+type ErrNoIDProvided struct{ gophercloud.ErrMissingInput }
+
+// ErrServer is a generic error type for servers HTTP operations.
+type ErrServer struct {
+	gophercloud.ErrUnexpectedResponseCode
+	ID string
+}
+
+func (se ErrServer) Error() string {
+	return fmt.Sprintf("Error while executing HTTP request for server [%s]", se.ID)
+}
+
+// Error404 overrides the generic 404 error message.
+func (se ErrServer) Error404(e gophercloud.ErrUnexpectedResponseCode) error {
+	se.ErrUnexpectedResponseCode = e
+	return &ErrServerNotFound{se}
+}
+
+// ErrServerNotFound is the error when a 404 is received during server HTTP
+// operations.
+type ErrServerNotFound struct {
+	ErrServer
+}
+
+func (e ErrServerNotFound) Error() string {
+	return fmt.Sprintf("I couldn't find server [%s]", e.ID)
+}
diff --git a/openstack/compute/v2/servers/fixtures.go b/openstack/compute/v2/servers/fixtures.go
new file mode 100644
index 0000000..224b996
--- /dev/null
+++ b/openstack/compute/v2/servers/fixtures.go
@@ -0,0 +1,706 @@
+// +build fixtures
+
+package servers
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ServerListBody contains the canned body of a servers.List response.
+const ServerListBody = `
+{
+	"servers": [
+		{
+			"status": "ACTIVE",
+			"updated": "2014-09-25T13:10:10Z",
+			"hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+			"OS-EXT-SRV-ATTR:host": "devstack",
+			"addresses": {
+				"private": [
+					{
+						"OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7c:1b:2b",
+						"version": 4,
+						"addr": "10.0.0.32",
+						"OS-EXT-IPS:type": "fixed"
+					}
+				]
+			},
+			"links": [
+				{
+					"href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+					"rel": "self"
+				},
+				{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+					"rel": "bookmark"
+				}
+			],
+			"key_name": null,
+			"image": {
+				"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+				"links": [
+					{
+						"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+						"rel": "bookmark"
+					}
+				]
+			},
+			"OS-EXT-STS:task_state": null,
+			"OS-EXT-STS:vm_state": "active",
+			"OS-EXT-SRV-ATTR:instance_name": "instance-0000001e",
+			"OS-SRV-USG:launched_at": "2014-09-25T13:10:10.000000",
+			"OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+			"flavor": {
+				"id": "1",
+				"links": [
+					{
+						"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+						"rel": "bookmark"
+					}
+				]
+			},
+			"id": "ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+			"security_groups": [
+				{
+					"name": "default"
+				}
+			],
+			"OS-SRV-USG:terminated_at": null,
+			"OS-EXT-AZ:availability_zone": "nova",
+			"user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+			"name": "herp",
+			"created": "2014-09-25T13:10:02Z",
+			"tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+			"OS-DCF:diskConfig": "MANUAL",
+			"os-extended-volumes:volumes_attached": [],
+			"accessIPv4": "",
+			"accessIPv6": "",
+			"progress": 0,
+			"OS-EXT-STS:power_state": 1,
+			"config_drive": "",
+			"metadata": {}
+		},
+		{
+			"status": "ACTIVE",
+			"updated": "2014-09-25T13:04:49Z",
+			"hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+			"OS-EXT-SRV-ATTR:host": "devstack",
+			"addresses": {
+				"private": [
+					{
+						"OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+						"version": 4,
+						"addr": "10.0.0.31",
+						"OS-EXT-IPS:type": "fixed"
+					}
+				]
+			},
+			"links": [
+				{
+					"href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+					"rel": "self"
+				},
+				{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+					"rel": "bookmark"
+				}
+			],
+			"key_name": null,
+			"image": {
+				"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+				"links": [
+					{
+						"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+						"rel": "bookmark"
+					}
+				]
+			},
+			"OS-EXT-STS:task_state": null,
+			"OS-EXT-STS:vm_state": "active",
+			"OS-EXT-SRV-ATTR:instance_name": "instance-0000001d",
+			"OS-SRV-USG:launched_at": "2014-09-25T13:04:49.000000",
+			"OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+			"flavor": {
+				"id": "1",
+				"links": [
+					{
+						"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+						"rel": "bookmark"
+					}
+				]
+			},
+			"id": "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+			"security_groups": [
+				{
+					"name": "default"
+				}
+			],
+			"OS-SRV-USG:terminated_at": null,
+			"OS-EXT-AZ:availability_zone": "nova",
+			"user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+			"name": "derp",
+			"created": "2014-09-25T13:04:41Z",
+			"tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+			"OS-DCF:diskConfig": "MANUAL",
+			"os-extended-volumes:volumes_attached": [],
+			"accessIPv4": "",
+			"accessIPv6": "",
+			"progress": 0,
+			"OS-EXT-STS:power_state": 1,
+			"config_drive": "",
+			"metadata": {}
+		}
+	]
+}
+`
+
+// SingleServerBody is the canned body of a Get request on an existing server.
+const SingleServerBody = `
+{
+	"server": {
+		"status": "ACTIVE",
+		"updated": "2014-09-25T13:04:49Z",
+		"hostId": "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+		"OS-EXT-SRV-ATTR:host": "devstack",
+		"addresses": {
+			"private": [
+				{
+					"OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+					"version": 4,
+					"addr": "10.0.0.31",
+					"OS-EXT-IPS:type": "fixed"
+				}
+			]
+		},
+		"links": [
+			{
+				"href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+				"rel": "self"
+			},
+			{
+				"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+				"rel": "bookmark"
+			}
+		],
+		"key_name": null,
+		"image": {
+			"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+			"links": [
+				{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+					"rel": "bookmark"
+				}
+			]
+		},
+		"OS-EXT-STS:task_state": null,
+		"OS-EXT-STS:vm_state": "active",
+		"OS-EXT-SRV-ATTR:instance_name": "instance-0000001d",
+		"OS-SRV-USG:launched_at": "2014-09-25T13:04:49.000000",
+		"OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack",
+		"flavor": {
+			"id": "1",
+			"links": [
+				{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+					"rel": "bookmark"
+				}
+			]
+		},
+		"id": "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+		"security_groups": [
+			{
+				"name": "default"
+			}
+		],
+		"OS-SRV-USG:terminated_at": null,
+		"OS-EXT-AZ:availability_zone": "nova",
+		"user_id": "9349aff8be7545ac9d2f1d00999a23cd",
+		"name": "derp",
+		"created": "2014-09-25T13:04:41Z",
+		"tenant_id": "fcad67a6189847c4aecfa3c81a05783b",
+		"OS-DCF:diskConfig": "MANUAL",
+		"os-extended-volumes:volumes_attached": [],
+		"accessIPv4": "",
+		"accessIPv6": "",
+		"progress": 0,
+		"OS-EXT-STS:power_state": 1,
+		"config_drive": "",
+		"metadata": {}
+	}
+}
+`
+
+var (
+	// ServerHerp is a Server struct that should correspond to the first result in ServerListBody.
+	ServerHerp = Server{
+		Status:  "ACTIVE",
+		Updated: "2014-09-25T13:10:10Z",
+		HostID:  "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+		Addresses: map[string]interface{}{
+			"private": []interface{}{
+				map[string]interface{}{
+					"OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7c:1b:2b",
+					"version":                 float64(4),
+					"addr":                    "10.0.0.32",
+					"OS-EXT-IPS:type":         "fixed",
+				},
+			},
+		},
+		Links: []interface{}{
+			map[string]interface{}{
+				"href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+				"rel":  "self",
+			},
+			map[string]interface{}{
+				"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+				"rel":  "bookmark",
+			},
+		},
+		Image: map[string]interface{}{
+			"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+			"links": []interface{}{
+				map[string]interface{}{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+					"rel":  "bookmark",
+				},
+			},
+		},
+		Flavor: map[string]interface{}{
+			"id": "1",
+			"links": []interface{}{
+				map[string]interface{}{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+					"rel":  "bookmark",
+				},
+			},
+		},
+		ID:       "ef079b0c-e610-4dfb-b1aa-b49f07ac48e5",
+		UserID:   "9349aff8be7545ac9d2f1d00999a23cd",
+		Name:     "herp",
+		Created:  "2014-09-25T13:10:02Z",
+		TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+		Metadata: map[string]interface{}{},
+		SecurityGroups: []map[string]interface{}{
+			map[string]interface{}{
+				"name": "default",
+			},
+		},
+	}
+
+	// ServerDerp is a Server struct that should correspond to the second server in ServerListBody.
+	ServerDerp = Server{
+		Status:  "ACTIVE",
+		Updated: "2014-09-25T13:04:49Z",
+		HostID:  "29d3c8c896a45aa4c34e52247875d7fefc3d94bbcc9f622b5d204362",
+		Addresses: map[string]interface{}{
+			"private": []interface{}{
+				map[string]interface{}{
+					"OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:9e:89:be",
+					"version":                 float64(4),
+					"addr":                    "10.0.0.31",
+					"OS-EXT-IPS:type":         "fixed",
+				},
+			},
+		},
+		Links: []interface{}{
+			map[string]interface{}{
+				"href": "http://104.130.131.164:8774/v2/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+				"rel":  "self",
+			},
+			map[string]interface{}{
+				"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/servers/9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+				"rel":  "bookmark",
+			},
+		},
+		Image: map[string]interface{}{
+			"id": "f90f6034-2570-4974-8351-6b49732ef2eb",
+			"links": []interface{}{
+				map[string]interface{}{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+					"rel":  "bookmark",
+				},
+			},
+		},
+		Flavor: map[string]interface{}{
+			"id": "1",
+			"links": []interface{}{
+				map[string]interface{}{
+					"href": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/flavors/1",
+					"rel":  "bookmark",
+				},
+			},
+		},
+		ID:       "9e5476bd-a4ec-4653-93d6-72c93aa682ba",
+		UserID:   "9349aff8be7545ac9d2f1d00999a23cd",
+		Name:     "derp",
+		Created:  "2014-09-25T13:04:41Z",
+		TenantID: "fcad67a6189847c4aecfa3c81a05783b",
+		Metadata: map[string]interface{}{},
+		SecurityGroups: []map[string]interface{}{
+			map[string]interface{}{
+				"name": "default",
+			},
+		},
+	}
+)
+
+type CreateOptsWithCustomField struct {
+	CreateOpts
+	Foo string `json:"foo,omitempty"`
+}
+
+func (opts CreateOptsWithCustomField) ToServerCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "server")
+}
+
+// HandleServerCreationSuccessfully sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationSuccessfully(t *testing.T, response string) {
+	th.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+			"server": {
+				"name": "derp",
+				"imageRef": "f90f6034-2570-4974-8351-6b49732ef2eb",
+				"flavorRef": "1"
+			}
+		}`)
+
+		w.WriteHeader(http.StatusAccepted)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, response)
+	})
+}
+
+// HandleServerCreationWithCustomFieldSuccessfully sets up the test server to respond to a server creation request
+// with a given response.
+func HandleServerCreationWithCustomFieldSuccessfully(t *testing.T, response string) {
+	th.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+			"server": {
+				"name": "derp",
+				"imageRef": "f90f6034-2570-4974-8351-6b49732ef2eb",
+				"flavorRef": "1",
+				"foo": "bar"
+			}
+		}`)
+
+		w.WriteHeader(http.StatusAccepted)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, response)
+	})
+}
+
+// HandleServerListSuccessfully sets up the test server to respond to a server List request.
+func HandleServerListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/detail", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, ServerListBody)
+		case "9e5476bd-a4ec-4653-93d6-72c93aa682ba":
+			fmt.Fprintf(w, `{ "servers": [] }`)
+		default:
+			t.Fatalf("/servers/detail invoked with unexpected marker=[%s]", marker)
+		}
+	})
+}
+
+// HandleServerDeletionSuccessfully sets up the test server to respond to a server deletion request.
+func HandleServerDeletionSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/asdfasdfasdf", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleServerForceDeletionSuccessfully sets up the test server to respond to a server password
+// change request.
+func HandleServerForceDeletionSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/asdfasdfasdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "forceDelete": "" }`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleServerGetSuccessfully sets up the test server to respond to a server Get request.
+func HandleServerGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		fmt.Fprintf(w, SingleServerBody)
+	})
+}
+
+// HandleServerUpdateSuccessfully sets up the test server to respond to a server Update request.
+func HandleServerUpdateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestJSONRequest(t, r, `{ "server": { "name": "new-name" } }`)
+
+		fmt.Fprintf(w, SingleServerBody)
+	})
+}
+
+// HandleAdminPasswordChangeSuccessfully sets up the test server to respond to a server password
+// change request.
+func HandleAdminPasswordChangeSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "changePassword": { "adminPass": "new-password" } }`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleRebootSuccessfully sets up the test server to respond to a reboot request with success.
+func HandleRebootSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "reboot": { "type": "SOFT" } }`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleRebuildSuccessfully sets up the test server to respond to a rebuild request with success.
+func HandleRebuildSuccessfully(t *testing.T, response string) {
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+			{
+				"rebuild": {
+					"name": "new-name",
+					"adminPass": "swordfish",
+					"imageRef": "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+					"accessIPv4": "1.2.3.4"
+				}
+			}
+		`)
+
+		w.WriteHeader(http.StatusAccepted)
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, response)
+	})
+}
+
+// HandleServerRescueSuccessfully sets up the test server to respond to a server Rescue request.
+func HandleServerRescueSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "rescue": { "adminPass": "1234567890" } }`)
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{ "adminPass": "1234567890" }`))
+	})
+}
+
+// HandleMetadatumGetSuccessfully sets up the test server to respond to a metadatum Get request.
+func HandleMetadatumGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", "application/json")
+		w.Write([]byte(`{ "meta": {"foo":"bar"}}`))
+	})
+}
+
+// HandleMetadatumCreateSuccessfully sets up the test server to respond to a metadatum Create request.
+func HandleMetadatumCreateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+			"meta": {
+				"foo": "bar"
+			}
+		}`)
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", "application/json")
+		w.Write([]byte(`{ "meta": {"foo":"bar"}}`))
+	})
+}
+
+// HandleMetadatumDeleteSuccessfully sets up the test server to respond to a metadatum Delete request.
+func HandleMetadatumDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata/foo", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleMetadataGetSuccessfully sets up the test server to respond to a metadata Get request.
+func HandleMetadataGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{ "metadata": {"foo":"bar", "this":"that"}}`))
+	})
+}
+
+// HandleMetadataResetSuccessfully sets up the test server to respond to a metadata Create request.
+func HandleMetadataResetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+				"metadata": {
+					"foo": "bar",
+					"this": "that"
+				}
+			}`)
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", "application/json")
+		w.Write([]byte(`{ "metadata": {"foo":"bar", "this":"that"}}`))
+	})
+}
+
+// HandleMetadataUpdateSuccessfully sets up the test server to respond to a metadata Update request.
+func HandleMetadataUpdateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf/metadata", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{
+				"metadata": {
+					"foo": "baz",
+					"this": "those"
+				}
+			}`)
+
+		w.WriteHeader(http.StatusOK)
+		w.Header().Add("Content-Type", "application/json")
+		w.Write([]byte(`{ "metadata": {"foo":"baz", "this":"those"}}`))
+	})
+}
+
+// ListAddressesExpected represents an expected repsonse from a ListAddresses request.
+var ListAddressesExpected = map[string][]Address{
+	"public": []Address{
+		Address{
+			Version: 4,
+			Address: "80.56.136.39",
+		},
+		Address{
+			Version: 6,
+			Address: "2001:4800:790e:510:be76:4eff:fe04:82a8",
+		},
+	},
+	"private": []Address{
+		Address{
+			Version: 4,
+			Address: "10.880.3.154",
+		},
+	},
+}
+
+// HandleAddressListSuccessfully sets up the test server to respond to a ListAddresses request.
+func HandleAddressListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/asdfasdfasdf/ips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"addresses": {
+				"public": [
+				{
+					"version": 4,
+					"addr": "50.56.176.35"
+				},
+				{
+					"version": 6,
+					"addr": "2001:4800:780e:510:be76:4eff:fe04:84a8"
+				}
+				],
+				"private": [
+				{
+					"version": 4,
+					"addr": "10.180.3.155"
+				}
+				]
+			}
+		}`)
+	})
+}
+
+// ListNetworkAddressesExpected represents an expected repsonse from a ListAddressesByNetwork request.
+var ListNetworkAddressesExpected = []Address{
+	Address{
+		Version: 4,
+		Address: "50.56.176.35",
+	},
+	Address{
+		Version: 6,
+		Address: "2001:4800:780e:510:be76:4eff:fe04:84a8",
+	},
+}
+
+// HandleNetworkAddressListSuccessfully sets up the test server to respond to a ListAddressesByNetwork request.
+func HandleNetworkAddressListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/asdfasdfasdf/ips/public", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{
+			"public": [
+			{
+				"version": 4,
+				"addr": "50.56.176.35"
+				},
+				{
+					"version": 6,
+					"addr": "2001:4800:780e:510:be76:4eff:fe04:84a8"
+				}
+			]
+			}`)
+	})
+}
+
+// HandleCreateServerImageSuccessfully sets up the test server to respond to a TestCreateServerImage request.
+func HandleCreateServerImageSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/serverimage/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		w.Header().Add("Location", "https://0.0.0.0/images/xxxx-xxxxx-xxxxx-xxxx")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go
new file mode 100644
index 0000000..27ab764
--- /dev/null
+++ b/openstack/compute/v2/servers/requests.go
@@ -0,0 +1,733 @@
+package servers
+
+import (
+	"encoding/base64"
+	"encoding/json"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/flavors"
+	"github.com/gophercloud/gophercloud/openstack/compute/v2/images"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToServerListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the server attributes you want to see returned. Marker and Limit are used
+// for pagination.
+type ListOpts struct {
+	// A time/date stamp for when the server last changed status.
+	ChangesSince string `q:"changes-since"`
+
+	// Name of the image in URL format.
+	Image string `q:"image"`
+
+	// Name of the flavor in URL format.
+	Flavor string `q:"flavor"`
+
+	// Name of the server as a string; can be queried with regular expressions.
+	// Realize that ?name=bob returns both bob and bobb. If you need to match bob
+	// only, you can use a regular expression matching the syntax of the
+	// underlying database server implemented for Compute.
+	Name string `q:"name"`
+
+	// Value of the status of the server so that you can filter on "ACTIVE" for example.
+	Status string `q:"status"`
+
+	// Name of the host as a string.
+	Host string `q:"host"`
+
+	// UUID of the server at which you want to set a marker.
+	Marker string `q:"marker"`
+
+	// Integer value for the limit of values to return.
+	Limit int `q:"limit"`
+
+	// Bool to show all tenants
+	AllTenants bool `q:"all_tenants"`
+}
+
+// ToServerListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToServerListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List makes a request against the API to list servers accessible to you.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listDetailURL(client)
+	if opts != nil {
+		query, err := opts.ToServerListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return ServerPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+// The CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+	ToServerCreateMap() (map[string]interface{}, error)
+}
+
+// Network is used within CreateOpts to control a new server's network attachments.
+type Network struct {
+	// UUID of a nova-network to attach to the newly provisioned server.
+	// Required unless Port is provided.
+	UUID string
+
+	// Port of a neutron network to attach to the newly provisioned server.
+	// Required unless UUID is provided.
+	Port string
+
+	// FixedIP [optional] specifies a fixed IPv4 address to be used on this network.
+	FixedIP string
+}
+
+// Personality is an array of files that are injected into the server at launch.
+type Personality []*File
+
+// File is used within CreateOpts and RebuildOpts to inject a file into the server at launch.
+// File implements the json.Marshaler interface, so when a Create or Rebuild operation is requested,
+// json.Marshal will call File's MarshalJSON method.
+type File struct {
+	// Path of the file
+	Path string
+	// Contents of the file. Maximum content size is 255 bytes.
+	Contents []byte
+}
+
+// MarshalJSON marshals the escaped file, base64 encoding the contents.
+func (f *File) MarshalJSON() ([]byte, error) {
+	file := struct {
+		Path     string `json:"path"`
+		Contents string `json:"contents"`
+	}{
+		Path:     f.Path,
+		Contents: base64.StdEncoding.EncodeToString(f.Contents),
+	}
+	return json.Marshal(file)
+}
+
+// CreateOpts specifies server creation parameters.
+type CreateOpts struct {
+	// Name is the name to assign to the newly launched server.
+	Name string `json:"name" required:"true"`
+
+	// ImageRef [optional; required if ImageName is not provided] is the ID or full
+	// URL to the image that contains the server's OS and initial state.
+	// Also optional if using the boot-from-volume extension.
+	ImageRef string `json:"imageRef"`
+
+	// ImageName [optional; required if ImageRef is not provided] is the name of the
+	// image that contains the server's OS and initial state.
+	// Also optional if using the boot-from-volume extension.
+	ImageName string `json:"-"`
+
+	// FlavorRef [optional; required if FlavorName is not provided] is the ID or
+	// full URL to the flavor that describes the server's specs.
+	FlavorRef string `json:"flavorRef"`
+
+	// FlavorName [optional; required if FlavorRef is not provided] is the name of
+	// the flavor that describes the server's specs.
+	FlavorName string `json:"-"`
+
+	// SecurityGroups lists the names of the security groups to which this server should belong.
+	SecurityGroups []string `json:"-"`
+
+	// UserData contains configuration information or scripts to use upon launch.
+	// Create will base64-encode it for you.
+	UserData []byte `json:"-"`
+
+	// AvailabilityZone in which to launch the server.
+	AvailabilityZone string `json:"availability_zone,omitempty"`
+
+	// Networks dictates how this server will be attached to available networks.
+	// By default, the server will be attached to all isolated networks for the tenant.
+	Networks []Network `json:"-"`
+
+	// Metadata contains key-value pairs (up to 255 bytes each) to attach to the server.
+	Metadata map[string]string `json:"-"`
+
+	// Personality includes files to inject into the server at launch.
+	// Create will base64-encode file contents for you.
+	Personality Personality `json:"-"`
+
+	// ConfigDrive enables metadata injection through a configuration drive.
+	ConfigDrive *bool `json:"config_drive,omitempty"`
+
+	// AdminPass sets the root user password. If not set, a randomly-generated
+	// password will be created and returned in the rponse.
+	AdminPass string `json:"adminPass,omitempty"`
+
+	// AccessIPv4 specifies an IPv4 address for the instance.
+	AccessIPv4 string `json:"accessIPv4,omitempty"`
+
+	// AccessIPv6 pecifies an IPv6 address for the instance.
+	AccessIPv6 string `json:"accessIPv6,omitempty"`
+
+	// ServiceClient will allow calls to be made to retrieve an image or
+	// flavor ID by name.
+	ServiceClient *gophercloud.ServiceClient `json:"-"`
+}
+
+// ToServerCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if opts.UserData != nil {
+		encoded := base64.StdEncoding.EncodeToString(opts.UserData)
+		b["user_data"] = &encoded
+	}
+
+	if len(opts.SecurityGroups) > 0 {
+		securityGroups := make([]map[string]interface{}, len(opts.SecurityGroups))
+		for i, groupName := range opts.SecurityGroups {
+			securityGroups[i] = map[string]interface{}{"name": groupName}
+		}
+		b["security_groups"] = securityGroups
+	}
+
+	if len(opts.Networks) > 0 {
+		networks := make([]map[string]interface{}, len(opts.Networks))
+		for i, net := range opts.Networks {
+			networks[i] = make(map[string]interface{})
+			if net.UUID != "" {
+				networks[i]["uuid"] = net.UUID
+			}
+			if net.Port != "" {
+				networks[i]["port"] = net.Port
+			}
+			if net.FixedIP != "" {
+				networks[i]["fixed_ip"] = net.FixedIP
+			}
+		}
+		b["networks"] = networks
+	}
+
+	// If ImageRef isn't provided, use ImageName to ascertain the image ID.
+	if opts.ImageRef == "" {
+		if opts.ImageName == "" {
+			err := ErrNeitherImageIDNorImageNameProvided{}
+			err.Argument = "ImageRef/ImageName"
+			return nil, err
+		}
+		if opts.ServiceClient == nil {
+			err := ErrNoClientProvidedForIDByName{}
+			err.Argument = "ServiceClient"
+			return nil, err
+		}
+		imageID, err := images.IDFromName(opts.ServiceClient, opts.ImageName)
+		if err != nil {
+			return nil, err
+		}
+		b["imageRef"] = imageID
+	}
+
+	// If FlavorRef isn't provided, use FlavorName to ascertain the flavor ID.
+	if opts.FlavorRef == "" {
+		if opts.FlavorName == "" {
+			err := ErrNeitherFlavorIDNorFlavorNameProvided{}
+			err.Argument = "FlavorRef/FlavorName"
+			return nil, err
+		}
+		if opts.ServiceClient == nil {
+			err := ErrNoClientProvidedForIDByName{}
+			err.Argument = "ServiceClient"
+			return nil, err
+		}
+		flavorID, err := flavors.IDFromName(opts.ServiceClient, opts.FlavorName)
+		if err != nil {
+			return nil, err
+		}
+		b["flavorRef"] = flavorID
+	}
+
+	return map[string]interface{}{"server": b}, nil
+}
+
+// Create requests a server to be provisioned to the user in the current tenant.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	reqBody, err := opts.ToServerCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(listURL(client), reqBody, &r.Body, nil)
+	return
+}
+
+// Delete requests that a server previously provisioned be removed from your account.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(deleteURL(client, id), nil)
+	return
+}
+
+// ForceDelete forces the deletion of a server
+func ForceDelete(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+	_, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"forceDelete": ""}, nil, nil)
+	return
+}
+
+// Get requests details on a single server, by ID.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 203},
+	})
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+	ToServerUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts struct {
+	// Name changes the displayed name of the server.
+	// The server host name will *not* change.
+	// Server names are not constrained to be unique, even within the same tenant.
+	Name string `json:"name,omitempty"`
+
+	// AccessIPv4 provides a new IPv4 address for the instance.
+	AccessIPv4 string `json:"accessIPv4,omitempty"`
+
+	// AccessIPv6 provides a new IPv6 address for the instance.
+	AccessIPv6 string `json:"accessIPv6,omitempty"`
+}
+
+// ToServerUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToServerUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "server")
+}
+
+// Update requests that various attributes of the indicated server be changed.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToServerUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// ChangeAdminPassword alters the administrator or root password for a specified server.
+func ChangeAdminPassword(client *gophercloud.ServiceClient, id, newPassword string) (r ActionResult) {
+	b := map[string]interface{}{
+		"changePassword": map[string]string{
+			"adminPass": newPassword,
+		},
+	}
+	_, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+	return
+}
+
+// RebootMethod describes the mechanisms by which a server reboot can be requested.
+type RebootMethod string
+
+// These constants determine how a server should be rebooted.
+// See the Reboot() function for further details.
+const (
+	SoftReboot RebootMethod = "SOFT"
+	HardReboot RebootMethod = "HARD"
+	OSReboot                = SoftReboot
+	PowerCycle              = HardReboot
+)
+
+// RebootOptsBuilder is an interface that options must satisfy in order to be
+// used when rebooting a server instance
+type RebootOptsBuilder interface {
+	ToServerRebootMap() (map[string]interface{}, error)
+}
+
+// RebootOpts satisfies the RebootOptsBuilder interface
+type RebootOpts struct {
+	Type RebootMethod `json:"type" required:"true"`
+}
+
+// ToServerRebootMap allows RebootOpts to satisfiy the RebootOptsBuilder
+// interface
+func (opts *RebootOpts) ToServerRebootMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "reboot")
+}
+
+// Reboot requests that a given server reboot.
+// Two methods exist for rebooting a server:
+//
+// HardReboot (aka PowerCycle) starts the server instance by physically cutting power to the machine, or if a VM,
+// terminating it at the hypervisor level.
+// It's done. Caput. Full stop.
+// Then, after a brief while, power is rtored or the VM instance rtarted.
+//
+// SoftReboot (aka OSReboot) simply tells the OS to rtart under its own procedur.
+// E.g., in Linux, asking it to enter runlevel 6, or executing "sudo shutdown -r now", or by asking Windows to rtart the machine.
+func Reboot(client *gophercloud.ServiceClient, id string, opts RebootOptsBuilder) (r ActionResult) {
+	b, err := opts.ToServerRebootMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+	return
+}
+
+// RebuildOptsBuilder is an interface that allows extensions to override the
+// default behaviour of rebuild options
+type RebuildOptsBuilder interface {
+	ToServerRebuildMap() (map[string]interface{}, error)
+}
+
+// RebuildOpts represents the configuration options used in a server rebuild
+// operation
+type RebuildOpts struct {
+	// The server's admin password
+	AdminPass string `json:"adminPass" required:"true"`
+	// The ID of the image you want your server to be provisioned on
+	ImageID   string `json:"imageRef"`
+	ImageName string `json:"-"`
+	//ImageName string `json:"-"`
+	// Name to set the server to
+	Name string `json:"name,omitempty"`
+	// AccessIPv4 [optional] provides a new IPv4 address for the instance.
+	AccessIPv4 string `json:"accessIPv4,omitempty"`
+	// AccessIPv6 [optional] provides a new IPv6 address for the instance.
+	AccessIPv6 string `json:"accessIPv6,omitempty"`
+	// Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+	Metadata map[string]string `json:"metadata,omitempty"`
+	// Personality [optional] includes files to inject into the server at launch.
+	// Rebuild will base64-encode file contents for you.
+	Personality   Personality                `json:"personality,omitempty"`
+	ServiceClient *gophercloud.ServiceClient `json:"-"`
+}
+
+// ToServerRebuildMap formats a RebuildOpts struct into a map for use in JSON
+func (opts RebuildOpts) ToServerRebuildMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	// If ImageRef isn't provided, use ImageName to ascertain the image ID.
+	if opts.ImageID == "" {
+		if opts.ImageName == "" {
+			err := ErrNeitherImageIDNorImageNameProvided{}
+			err.Argument = "ImageRef/ImageName"
+			return nil, err
+		}
+		if opts.ServiceClient == nil {
+			err := ErrNoClientProvidedForIDByName{}
+			err.Argument = "ServiceClient"
+			return nil, err
+		}
+		imageID, err := images.IDFromName(opts.ServiceClient, opts.ImageName)
+		if err != nil {
+			return nil, err
+		}
+		b["imageRef"] = imageID
+	}
+
+	return map[string]interface{}{"rebuild": b}, nil
+}
+
+// Rebuild will reprovision the server according to the configuration options
+// provided in the RebuildOpts struct.
+func Rebuild(client *gophercloud.ServiceClient, id string, opts RebuildOptsBuilder) (r RebuildResult) {
+	b, err := opts.ToServerRebuildMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(actionURL(client, id), b, &r.Body, nil)
+	return
+}
+
+// ResizeOptsBuilder is an interface that allows extensions to override the default structure of
+// a Resize request.
+type ResizeOptsBuilder interface {
+	ToServerResizeMap() (map[string]interface{}, error)
+}
+
+// ResizeOpts represents the configuration options used to control a Resize operation.
+type ResizeOpts struct {
+	// FlavorRef is the ID of the flavor you wish your server to become.
+	FlavorRef string `json:"flavorRef" required:"true"`
+}
+
+// ToServerResizeMap formats a ResizeOpts as a map that can be used as a JSON request body for the
+// Resize request.
+func (opts ResizeOpts) ToServerResizeMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "resize")
+}
+
+// Resize instructs the provider to change the flavor of the server.
+// Note that this implies rebuilding it.
+// Unfortunately, one cannot pass rebuild parameters to the rize function.
+// When the rize completes, the server will be in RESIZE_VERIFY state.
+// While in this state, you can explore the use of the new server's configuration.
+// If you like it, call ConfirmResize() to commit the rize permanently.
+// Otherwise, call RevertResize() to rtore the old configuration.
+func Resize(client *gophercloud.ServiceClient, id string, opts ResizeOptsBuilder) (r ActionResult) {
+	b, err := opts.ToServerResizeMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(actionURL(client, id), b, nil, nil)
+	return
+}
+
+// ConfirmResize confirms a previous rize operation on a server.
+// See Resize() for more details.
+func ConfirmResize(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+	_, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"confirmResize": nil}, nil, &gophercloud.RequestOpts{
+		OkCodes: []int{201, 202, 204},
+	})
+	return
+}
+
+// RevertResize cancels a previous rize operation on a server.
+// See Resize() for more details.
+func RevertResize(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+	_, r.Err = client.Post(actionURL(client, id), map[string]interface{}{"revertResize": nil}, nil, nil)
+	return
+}
+
+// RescueOptsBuilder is an interface that allows extensions to override the
+// default structure of a Rescue request.
+type RescueOptsBuilder interface {
+	ToServerRescueMap() (map[string]interface{}, error)
+}
+
+// RescueOpts represents the configuration options used to control a Rescue
+// option.
+type RescueOpts struct {
+	// AdminPass is the desired administrative password for the instance in
+	// RESCUE mode. If it's left blank, the server will generate a password.
+	AdminPass string `json:"adminPass,omitempty"`
+}
+
+// ToServerRescueMap formats a RescueOpts as a map that can be used as a JSON
+// request body for the Rescue request.
+func (opts RescueOpts) ToServerRescueMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "rescue")
+}
+
+// Rescue instructs the provider to place the server into RESCUE mode.
+func Rescue(client *gophercloud.ServiceClient, id string, opts RescueOptsBuilder) (r RescueResult) {
+	b, err := opts.ToServerRescueMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(actionURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// ResetMetadataOptsBuilder allows extensions to add additional parameters to the
+// Reset request.
+type ResetMetadataOptsBuilder interface {
+	ToMetadataResetMap() (map[string]interface{}, error)
+}
+
+// MetadataOpts is a map that contains key-value pairs.
+type MetadataOpts map[string]string
+
+// ToMetadataResetMap assembles a body for a Reset request based on the contents of a MetadataOpts.
+func (opts MetadataOpts) ToMetadataResetMap() (map[string]interface{}, error) {
+	return map[string]interface{}{"metadata": opts}, nil
+}
+
+// ToMetadataUpdateMap assembles a body for an Update request based on the contents of a MetadataOpts.
+func (opts MetadataOpts) ToMetadataUpdateMap() (map[string]interface{}, error) {
+	return map[string]interface{}{"metadata": opts}, nil
+}
+
+// ResetMetadata will create multiple new key-value pairs for the given server ID.
+// Note: Using this operation will erase any already-existing metadata and create
+// the new metadata provided. To keep any already-existing metadata, use the
+// UpdateMetadatas or UpdateMetadata function.
+func ResetMetadata(client *gophercloud.ServiceClient, id string, opts ResetMetadataOptsBuilder) (r ResetMetadataResult) {
+	b, err := opts.ToMetadataResetMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(metadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Metadata requests all the metadata for the given server ID.
+func Metadata(client *gophercloud.ServiceClient, id string) (r GetMetadataResult) {
+	_, r.Err = client.Get(metadataURL(client, id), &r.Body, nil)
+	return
+}
+
+// UpdateMetadataOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type UpdateMetadataOptsBuilder interface {
+	ToMetadataUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateMetadata updates (or creates) all the metadata specified by opts for the given server ID.
+// This operation does not affect already-existing metadata that is not specified
+// by opts.
+func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts UpdateMetadataOptsBuilder) (r UpdateMetadataResult) {
+	b, err := opts.ToMetadataUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(metadataURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// MetadatumOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type MetadatumOptsBuilder interface {
+	ToMetadatumCreateMap() (map[string]interface{}, string, error)
+}
+
+// MetadatumOpts is a map of length one that contains a key-value pair.
+type MetadatumOpts map[string]string
+
+// ToMetadatumCreateMap assembles a body for a Create request based on the contents of a MetadataumOpts.
+func (opts MetadatumOpts) ToMetadatumCreateMap() (map[string]interface{}, string, error) {
+	if len(opts) != 1 {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "servers.MetadatumOpts"
+		err.Info = "Must have 1 and only 1 key-value pair"
+		return nil, "", err
+	}
+	metadatum := map[string]interface{}{"meta": opts}
+	var key string
+	for k := range metadatum["meta"].(MetadatumOpts) {
+		key = k
+	}
+	return metadatum, key, nil
+}
+
+// CreateMetadatum will create or update the key-value pair with the given key for the given server ID.
+func CreateMetadatum(client *gophercloud.ServiceClient, id string, opts MetadatumOptsBuilder) (r CreateMetadatumResult) {
+	b, key, err := opts.ToMetadatumCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(metadatumURL(client, id, key), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Metadatum requests the key-value pair with the given key for the given server ID.
+func Metadatum(client *gophercloud.ServiceClient, id, key string) (r GetMetadatumResult) {
+	_, r.Err = client.Get(metadatumURL(client, id, key), &r.Body, nil)
+	return
+}
+
+// DeleteMetadatum will delete the key-value pair with the given key for the given server ID.
+func DeleteMetadatum(client *gophercloud.ServiceClient, id, key string) (r DeleteMetadatumResult) {
+	_, r.Err = client.Delete(metadatumURL(client, id, key), nil)
+	return
+}
+
+// ListAddresses makes a request against the API to list the servers IP addresses.
+func ListAddresses(client *gophercloud.ServiceClient, id string) pagination.Pager {
+	return pagination.NewPager(client, listAddressesURL(client, id), func(r pagination.PageResult) pagination.Page {
+		return AddressPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// ListAddressesByNetwork makes a request against the API to list the servers IP addresses
+// for the given network.
+func ListAddressesByNetwork(client *gophercloud.ServiceClient, id, network string) pagination.Pager {
+	return pagination.NewPager(client, listAddressesByNetworkURL(client, id, network), func(r pagination.PageResult) pagination.Page {
+		return NetworkAddressPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateImageOptsBuilder is the interface types must satisfy in order to be
+// used as CreateImage options
+type CreateImageOptsBuilder interface {
+	ToServerCreateImageMap() (map[string]interface{}, error)
+}
+
+// CreateImageOpts satisfies the CreateImageOptsBuilder
+type CreateImageOpts struct {
+	// Name of the image/snapshot
+	Name string `json:"name" required:"true"`
+	// Metadata contains key-value pairs (up to 255 bytes each) to attach to the created image.
+	Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// ToServerCreateImageMap formats a CreateImageOpts structure into a request body.
+func (opts CreateImageOpts) ToServerCreateImageMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "createImage")
+}
+
+// CreateImage makes a request against the nova API to schedule an image to be created of the server
+func CreateImage(client *gophercloud.ServiceClient, id string, opts CreateImageOptsBuilder) (r CreateImageResult) {
+	b, err := opts.ToServerCreateImageMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	resp, err := client.Post(actionURL(client, id), b, nil, &gophercloud.RequestOpts{
+		OkCodes: []int{202},
+	})
+	r.Err = err
+	r.Header = resp.Header
+	return
+}
+
+// IDFromName is a convienience function that returns a server's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	allPages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractServers(allPages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, f := range all {
+		if f.Name == name {
+			count++
+			id = f.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "server"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "server"}
+	}
+}
diff --git a/openstack/compute/v2/servers/requests_test.go b/openstack/compute/v2/servers/requests_test.go
new file mode 100644
index 0000000..931ab36
--- /dev/null
+++ b/openstack/compute/v2/servers/requests_test.go
@@ -0,0 +1,402 @@
+package servers
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListServers(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerListSuccessfully(t)
+
+	pages := 0
+	err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractServers(page)
+		if err != nil {
+			return false, err
+		}
+
+		if len(actual) != 2 {
+			t.Fatalf("Expected 2 servers, got %d", len(actual))
+		}
+		th.CheckDeepEquals(t, ServerHerp, actual[0])
+		th.CheckDeepEquals(t, ServerDerp, actual[1])
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+
+	if pages != 1 {
+		t.Errorf("Expected 1 page, saw %d", pages)
+	}
+}
+
+func TestListAllServers(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerListSuccessfully(t)
+
+	allPages, err := List(client.ServiceClient(), ListOpts{}).AllPages()
+	th.AssertNoErr(t, err)
+	actual, err := ExtractServers(allPages)
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ServerHerp, actual[0])
+	th.CheckDeepEquals(t, ServerDerp, actual[1])
+}
+
+func TestCreateServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerCreationSuccessfully(t, SingleServerBody)
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Name:      "derp",
+		ImageRef:  "f90f6034-2570-4974-8351-6b49732ef2eb",
+		FlavorRef: "1",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestCreateServerWithCustomField(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerCreationWithCustomFieldSuccessfully(t, SingleServerBody)
+
+	actual, err := Create(client.ServiceClient(), CreateOptsWithCustomField{
+		CreateOpts: CreateOpts{
+			Name:      "derp",
+			ImageRef:  "f90f6034-2570-4974-8351-6b49732ef2eb",
+			FlavorRef: "1",
+		},
+		Foo: "bar",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestDeleteServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerDeletionSuccessfully(t)
+
+	res := Delete(client.ServiceClient(), "asdfasdfasdf")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestForceDeleteServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerForceDeletionSuccessfully(t)
+
+	res := ForceDelete(client.ServiceClient(), "asdfasdfasdf")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestGetServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerGetSuccessfully(t)
+
+	client := client.ServiceClient()
+	actual, err := Get(client, "1234asdf").Extract()
+	if err != nil {
+		t.Fatalf("Unexpected Get error: %v", err)
+	}
+
+	th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestUpdateServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleServerUpdateSuccessfully(t)
+
+	client := client.ServiceClient()
+	actual, err := Update(client, "1234asdf", UpdateOpts{Name: "new-name"}).Extract()
+	if err != nil {
+		t.Fatalf("Unexpected Update error: %v", err)
+	}
+
+	th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestChangeServerAdminPassword(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAdminPasswordChangeSuccessfully(t)
+
+	res := ChangeAdminPassword(client.ServiceClient(), "1234asdf", "new-password")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestRebootServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleRebootSuccessfully(t)
+
+	res := Reboot(client.ServiceClient(), "1234asdf", &RebootOpts{
+		Type: SoftReboot,
+	})
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestRebuildServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleRebuildSuccessfully(t, SingleServerBody)
+
+	opts := RebuildOpts{
+		Name:       "new-name",
+		AdminPass:  "swordfish",
+		ImageID:    "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+		AccessIPv4: "1.2.3.4",
+	}
+
+	actual, err := Rebuild(client.ServiceClient(), "1234asdf", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.CheckDeepEquals(t, ServerDerp, *actual)
+}
+
+func TestResizeServer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "resize": { "flavorRef": "2" } }`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+
+	res := Resize(client.ServiceClient(), "1234asdf", ResizeOpts{FlavorRef: "2"})
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestConfirmResize(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "confirmResize": null }`)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := ConfirmResize(client.ServiceClient(), "1234asdf")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestRevertResize(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/servers/1234asdf/action", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "revertResize": null }`)
+
+		w.WriteHeader(http.StatusAccepted)
+	})
+
+	res := RevertResize(client.ServiceClient(), "1234asdf")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestRescue(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleServerRescueSuccessfully(t)
+
+	res := Rescue(client.ServiceClient(), "1234asdf", RescueOpts{
+		AdminPass: "1234567890",
+	})
+	th.AssertNoErr(t, res.Err)
+	adminPass, _ := res.Extract()
+	th.AssertEquals(t, "1234567890", adminPass)
+}
+
+func TestGetMetadatum(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadatumGetSuccessfully(t)
+
+	expected := map[string]string{"foo": "bar"}
+	actual, err := Metadatum(client.ServiceClient(), "1234asdf", "foo").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestCreateMetadatum(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadatumCreateSuccessfully(t)
+
+	expected := map[string]string{"foo": "bar"}
+	actual, err := CreateMetadatum(client.ServiceClient(), "1234asdf", MetadatumOpts{"foo": "bar"}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteMetadatum(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadatumDeleteSuccessfully(t)
+
+	err := DeleteMetadatum(client.ServiceClient(), "1234asdf", "foo").ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestGetMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadataGetSuccessfully(t)
+
+	expected := map[string]string{"foo": "bar", "this": "that"}
+	actual, err := Metadata(client.ServiceClient(), "1234asdf").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestResetMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadataResetSuccessfully(t)
+
+	expected := map[string]string{"foo": "bar", "this": "that"}
+	actual, err := ResetMetadata(client.ServiceClient(), "1234asdf", MetadataOpts{
+		"foo":  "bar",
+		"this": "that",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleMetadataUpdateSuccessfully(t)
+
+	expected := map[string]string{"foo": "baz", "this": "those"}
+	actual, err := UpdateMetadata(client.ServiceClient(), "1234asdf", MetadataOpts{
+		"foo":  "baz",
+		"this": "those",
+	}).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListAddresses(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAddressListSuccessfully(t)
+
+	expected := ListAddressesExpected
+	pages := 0
+	err := ListAddresses(client.ServiceClient(), "asdfasdfasdf").EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractAddresses(page)
+		th.AssertNoErr(t, err)
+
+		if len(actual) != 2 {
+			t.Fatalf("Expected 2 networks, got %d", len(actual))
+		}
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, pages)
+}
+
+func TestListAddressesByNetwork(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleNetworkAddressListSuccessfully(t)
+
+	expected := ListNetworkAddressesExpected
+	pages := 0
+	err := ListAddressesByNetwork(client.ServiceClient(), "asdfasdfasdf", "public").EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractNetworkAddresses(page)
+		th.AssertNoErr(t, err)
+
+		if len(actual) != 2 {
+			t.Fatalf("Expected 2 addresses, got %d", len(actual))
+		}
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, pages)
+}
+
+func TestCreateServerImage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateServerImageSuccessfully(t)
+
+	_, err := CreateImage(client.ServiceClient(), "serverimage", CreateImageOpts{Name: "test"}).ExtractImageID()
+	th.AssertNoErr(t, err)
+}
+
+func TestMarshalPersonality(t *testing.T) {
+	name := "/etc/test"
+	contents := []byte("asdfasdf")
+
+	personality := Personality{
+		&File{
+			Path:     name,
+			Contents: contents,
+		},
+	}
+
+	data, err := json.Marshal(personality)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var actual []map[string]string
+	err = json.Unmarshal(data, &actual)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(actual) != 1 {
+		t.Fatal("expected personality length 1")
+	}
+
+	if actual[0]["path"] != name {
+		t.Fatal("file path incorrect")
+	}
+
+	if actual[0]["contents"] != base64.StdEncoding.EncodeToString(contents) {
+		t.Fatal("file contents incorrect")
+	}
+}
diff --git a/openstack/compute/v2/servers/results.go b/openstack/compute/v2/servers/results.go
new file mode 100644
index 0000000..ff2e795
--- /dev/null
+++ b/openstack/compute/v2/servers/results.go
@@ -0,0 +1,296 @@
+package servers
+
+import (
+	"fmt"
+	"net/url"
+	"path"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type serverResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets any serverResult as a Server, if possible.
+func (r serverResult) Extract() (*Server, error) {
+	var s struct {
+		Server *Server `json:"server"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Server, err
+}
+
+// CreateResult temporarily contains the response from a Create call.
+type CreateResult struct {
+	serverResult
+}
+
+// GetResult temporarily contains the response from a Get call.
+type GetResult struct {
+	serverResult
+}
+
+// UpdateResult temporarily contains the response from an Update call.
+type UpdateResult struct {
+	serverResult
+}
+
+// DeleteResult temporarily contains the response from a Delete call.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// RebuildResult temporarily contains the response from a Rebuild call.
+type RebuildResult struct {
+	serverResult
+}
+
+// ActionResult represents the result of server action operations, like reboot
+type ActionResult struct {
+	gophercloud.ErrResult
+}
+
+// RescueResult represents the result of a server rescue operation
+type RescueResult struct {
+	ActionResult
+}
+
+// CreateImageResult represents the result of an image creation operation
+type CreateImageResult struct {
+	gophercloud.Result
+}
+
+// ExtractImageID gets the ID of the newly created server image from the header
+func (res CreateImageResult) ExtractImageID() (string, error) {
+	if res.Err != nil {
+		return "", res.Err
+	}
+	// Get the image id from the header
+	u, err := url.ParseRequestURI(res.Header.Get("Location"))
+	if err != nil {
+		return "", err
+	}
+	imageID := path.Base(u.Path)
+	if imageID == "." || imageID == "/" {
+		return "", fmt.Errorf("Failed to parse the ID of newly created image: %s", u)
+	}
+	return imageID, nil
+}
+
+// Extract interprets any RescueResult as an AdminPass, if possible.
+func (r RescueResult) Extract() (string, error) {
+	var s struct {
+		AdminPass string `json:"adminPass"`
+	}
+	err := r.ExtractInto(&s)
+	return s.AdminPass, err
+}
+
+// Server exposes only the standard OpenStack fields corresponding to a given server on the user's account.
+type Server struct {
+	// ID uniquely identifies this server amongst all other servers, including those not accessible to the current tenant.
+	ID string `json:"id"`
+
+	// TenantID identifies the tenant owning this server resource.
+	TenantID string `json:"tenant_id"`
+
+	// UserID uniquely identifies the user account owning the tenant.
+	UserID string `json:"user_id"`
+
+	// Name contains the human-readable name for the server.
+	Name string `json:"name"`
+
+	// Updated and Created contain ISO-8601 timestamps of when the state of the server last changed, and when it was created.
+	Updated string
+	Created string
+
+	HostID string
+
+	// Status contains the current operational status of the server, such as IN_PROGRESS or ACTIVE.
+	Status string
+
+	// Progress ranges from 0..100.
+	// A request made against the server completes only once Progress reaches 100.
+	Progress int
+
+	// AccessIPv4 and AccessIPv6 contain the IP addresses of the server, suitable for remote access for administration.
+	AccessIPv4, AccessIPv6 string
+
+	// Image refers to a JSON object, which itself indicates the OS image used to deploy the server.
+	Image map[string]interface{}
+
+	// Flavor refers to a JSON object, which itself indicates the hardware configuration of the deployed server.
+	Flavor map[string]interface{}
+
+	// Addresses includes a list of all IP addresses assigned to the server, keyed by pool.
+	Addresses map[string]interface{}
+
+	// Metadata includes a list of all user-specified key-value pairs attached to the server.
+	Metadata map[string]interface{}
+
+	// Links includes HTTP references to the itself, useful for passing along to other APIs that might want a server reference.
+	Links []interface{}
+
+	// KeyName indicates which public key was injected into the server on launch.
+	KeyName string `json:"key_name"`
+
+	// AdminPass will generally be empty ("").  However, it will contain the administrative password chosen when provisioning a new server without a set AdminPass setting in the first place.
+	// Note that this is the ONLY time this field will be valid.
+	AdminPass string `json:"adminPass"`
+
+	// SecurityGroups includes the security groups that this instance has applied to it
+	SecurityGroups []map[string]interface{} `json:"security_groups"`
+}
+
+// ServerPage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractServers call.
+type ServerPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (page ServerPage) IsEmpty() (bool, error) {
+	servers, err := ExtractServers(page)
+	return len(servers) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page ServerPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"servers_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractServers interprets the results of a single page from a List() call, producing a slice of Server entities.
+func ExtractServers(r pagination.Page) ([]Server, error) {
+	var s struct {
+		Servers []Server `json:"servers"`
+	}
+	err := (r.(ServerPage)).ExtractInto(&s)
+	return s.Servers, err
+}
+
+// MetadataResult contains the result of a call for (potentially) multiple key-value pairs.
+type MetadataResult struct {
+	gophercloud.Result
+}
+
+// GetMetadataResult temporarily contains the response from a metadata Get call.
+type GetMetadataResult struct {
+	MetadataResult
+}
+
+// ResetMetadataResult temporarily contains the response from a metadata Reset call.
+type ResetMetadataResult struct {
+	MetadataResult
+}
+
+// UpdateMetadataResult temporarily contains the response from a metadata Update call.
+type UpdateMetadataResult struct {
+	MetadataResult
+}
+
+// MetadatumResult contains the result of a call for individual a single key-value pair.
+type MetadatumResult struct {
+	gophercloud.Result
+}
+
+// GetMetadatumResult temporarily contains the response from a metadatum Get call.
+type GetMetadatumResult struct {
+	MetadatumResult
+}
+
+// CreateMetadatumResult temporarily contains the response from a metadatum Create call.
+type CreateMetadatumResult struct {
+	MetadatumResult
+}
+
+// DeleteMetadatumResult temporarily contains the response from a metadatum Delete call.
+type DeleteMetadatumResult struct {
+	gophercloud.ErrResult
+}
+
+// Extract interprets any MetadataResult as a Metadata, if possible.
+func (r MetadataResult) Extract() (map[string]string, error) {
+	var s struct {
+		Metadata map[string]string `json:"metadata"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Metadata, err
+}
+
+// Extract interprets any MetadatumResult as a Metadatum, if possible.
+func (r MetadatumResult) Extract() (map[string]string, error) {
+	var s struct {
+		Metadatum map[string]string `json:"meta"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Metadatum, err
+}
+
+// Address represents an IP address.
+type Address struct {
+	Version int    `json:"version"`
+	Address string `json:"addr"`
+}
+
+// AddressPage abstracts the raw results of making a ListAddresses() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned
+// to the client, you may only safely access the data provided through the ExtractAddresses call.
+type AddressPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if an AddressPage contains no networks.
+func (r AddressPage) IsEmpty() (bool, error) {
+	addresses, err := ExtractAddresses(r)
+	return len(addresses) == 0, err
+}
+
+// ExtractAddresses interprets the results of a single page from a ListAddresses() call,
+// producing a map of addresses.
+func ExtractAddresses(r pagination.Page) (map[string][]Address, error) {
+	var s struct {
+		Addresses map[string][]Address `json:"addresses"`
+	}
+	err := (r.(AddressPage)).ExtractInto(&s)
+	return s.Addresses, err
+}
+
+// NetworkAddressPage abstracts the raw results of making a ListAddressesByNetwork() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned
+// to the client, you may only safely access the data provided through the ExtractAddresses call.
+type NetworkAddressPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a NetworkAddressPage contains no addresses.
+func (r NetworkAddressPage) IsEmpty() (bool, error) {
+	addresses, err := ExtractNetworkAddresses(r)
+	return len(addresses) == 0, err
+}
+
+// ExtractNetworkAddresses interprets the results of a single page from a ListAddressesByNetwork() call,
+// producing a slice of addresses.
+func ExtractNetworkAddresses(r pagination.Page) ([]Address, error) {
+	var s map[string][]Address
+	err := (r.(NetworkAddressPage)).ExtractInto(&s)
+	if err != nil {
+		return nil, err
+	}
+
+	var key string
+	for k := range s {
+		key = k
+	}
+
+	return s[key], err
+}
diff --git a/openstack/compute/v2/servers/urls.go b/openstack/compute/v2/servers/urls.go
new file mode 100644
index 0000000..a11504c
--- /dev/null
+++ b/openstack/compute/v2/servers/urls.go
@@ -0,0 +1,47 @@
+package servers
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("servers")
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return createURL(client)
+}
+
+func listDetailURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("servers", "detail")
+}
+
+func deleteURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("servers", id)
+}
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+	return deleteURL(client, id)
+}
+
+func updateURL(client *gophercloud.ServiceClient, id string) string {
+	return deleteURL(client, id)
+}
+
+func actionURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("servers", id, "action")
+}
+
+func metadatumURL(client *gophercloud.ServiceClient, id, key string) string {
+	return client.ServiceURL("servers", id, "metadata", key)
+}
+
+func metadataURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("servers", id, "metadata")
+}
+
+func listAddressesURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("servers", id, "ips")
+}
+
+func listAddressesByNetworkURL(client *gophercloud.ServiceClient, id, network string) string {
+	return client.ServiceURL("servers", id, "ips", network)
+}
diff --git a/openstack/compute/v2/servers/util.go b/openstack/compute/v2/servers/util.go
new file mode 100644
index 0000000..494a0e4
--- /dev/null
+++ b/openstack/compute/v2/servers/util.go
@@ -0,0 +1,20 @@
+package servers
+
+import "github.com/gophercloud/gophercloud"
+
+// WaitForStatus will continually poll a server until it successfully transitions to a specified
+// status. It will do this for at most the number of seconds specified.
+func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		current, err := Get(c, id).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if current.Status == status {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
diff --git a/openstack/db/v1/configurations/doc.go b/openstack/db/v1/configurations/doc.go
new file mode 100644
index 0000000..45b9cfb
--- /dev/null
+++ b/openstack/db/v1/configurations/doc.go
@@ -0,0 +1,11 @@
+// Package configurations provides information and interaction with the
+// configuration API resource in the Rackspace Database service.
+//
+// A configuration group is a collection of key/value pairs which define how a
+// particular database operates. These key/value pairs are specific to each
+// datastore type and serve like settings. Some directives are capable of being
+// applied dynamically, while other directives require a server restart to take
+// effect. The configuration group can be applied to an instance at creation or
+// applied to an existing instance to modify the behavior of the running
+// datastore on the instance.
+package configurations
diff --git a/openstack/db/v1/configurations/fixtures.go b/openstack/db/v1/configurations/fixtures.go
new file mode 100644
index 0000000..ae65416
--- /dev/null
+++ b/openstack/db/v1/configurations/fixtures.go
@@ -0,0 +1,157 @@
+package configurations
+
+import (
+	"fmt"
+	"time"
+)
+
+var (
+	timestamp  = "2015-11-12T14:22:42Z"
+	timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var singleConfigJSON = `
+{
+  "created": "` + timestamp + `",
+  "datastore_name": "mysql",
+  "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+  "datastore_version_name": "5.6",
+  "description": "example_description",
+  "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+  "name": "example-configuration-name",
+  "updated": "` + timestamp + `"
+}
+`
+
+var singleConfigWithValuesJSON = `
+{
+  "created": "` + timestamp + `",
+  "datastore_name": "mysql",
+  "datastore_version_id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+  "datastore_version_name": "5.6",
+  "description": "example description",
+  "id": "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+  "instance_count": 0,
+  "name": "example-configuration-name",
+  "updated": "` + timestamp + `",
+  "values": {
+    "collation_server": "latin1_swedish_ci",
+    "connect_timeout": 120
+  }
+}
+`
+
+var (
+	ListConfigsJSON  = fmt.Sprintf(`{"configurations": [%s]}`, singleConfigJSON)
+	GetConfigJSON    = fmt.Sprintf(`{"configuration": %s}`, singleConfigJSON)
+	CreateConfigJSON = fmt.Sprintf(`{"configuration": %s}`, singleConfigWithValuesJSON)
+)
+
+var CreateReq = `
+{
+  "configuration": {
+    "datastore": {
+      "type": "a00000a0-00a0-0a00-00a0-000a000000aa",
+      "version": "b00000b0-00b0-0b00-00b0-000b000000bb"
+    },
+    "description": "example description",
+    "name": "example-configuration-name",
+    "values": {
+      "collation_server": "latin1_swedish_ci",
+      "connect_timeout": 120
+    }
+  }
+}
+`
+
+var UpdateReq = `
+{
+  "configuration": {
+    "values": {
+      "connect_timeout": 300
+    }
+  }
+}
+`
+
+var ListInstancesJSON = `
+{
+  "instances": [
+    {
+      "id": "d4603f69-ec7e-4e9b-803f-600b9205576f",
+      "name": "json_rack_instance"
+    }
+  ]
+}
+`
+
+var ListParamsJSON = `
+{
+  "configuration-parameters": [
+    {
+      "max": 1,
+      "min": 0,
+      "name": "innodb_file_per_table",
+      "restart_required": true,
+      "type": "integer"
+    },
+    {
+      "max": 4294967296,
+      "min": 0,
+      "name": "key_buffer_size",
+      "restart_required": false,
+      "type": "integer"
+    },
+    {
+      "max": 65535,
+      "min": 2,
+      "name": "connect_timeout",
+      "restart_required": false,
+      "type": "integer"
+    },
+    {
+      "max": 4294967296,
+      "min": 0,
+      "name": "join_buffer_size",
+      "restart_required": false,
+      "type": "integer"
+    }
+  ]
+}
+`
+
+var GetParamJSON = `
+{
+  "max": 1,
+  "min": 0,
+  "name": "innodb_file_per_table",
+  "restart_required": true,
+  "type": "integer"
+}
+`
+
+var ExampleConfig = Config{
+	Created:              timeVal,
+	DatastoreName:        "mysql",
+	DatastoreVersionID:   "b00000b0-00b0-0b00-00b0-000b000000bb",
+	DatastoreVersionName: "5.6",
+	Description:          "example_description",
+	ID:                   "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+	Name:                 "example-configuration-name",
+	Updated:              timeVal,
+}
+
+var ExampleConfigWithValues = Config{
+	Created:              timeVal,
+	DatastoreName:        "mysql",
+	DatastoreVersionID:   "b00000b0-00b0-0b00-00b0-000b000000bb",
+	DatastoreVersionName: "5.6",
+	Description:          "example description",
+	ID:                   "005a8bb7-a8df-40ee-b0b7-fc144641abc2",
+	Name:                 "example-configuration-name",
+	Updated:              timeVal,
+	Values: map[string]interface{}{
+		"collation_server": "latin1_swedish_ci",
+		"connect_timeout":  120,
+	},
+}
diff --git a/openstack/db/v1/configurations/requests.go b/openstack/db/v1/configurations/requests.go
new file mode 100644
index 0000000..8fc8295
--- /dev/null
+++ b/openstack/db/v1/configurations/requests.go
@@ -0,0 +1,167 @@
+package configurations
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will list all of the available configurations.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+		return ConfigPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CreateOptsBuilder is a top-level interface which renders a JSON map.
+type CreateOptsBuilder interface {
+	ToConfigCreateMap() (map[string]interface{}, error)
+}
+
+// DatastoreOpts is the primary options struct for creating and modifying
+// how configuration resources are associated with datastores.
+type DatastoreOpts struct {
+	// The type of datastore. Defaults to "MySQL".
+	Type string `json:"type,omitempty"`
+	// The specific version of a datastore. Defaults to "5.6".
+	Version string `json:"version,omitempty"`
+}
+
+// CreateOpts is the struct responsible for configuring new configurations.
+type CreateOpts struct {
+	// The configuration group name
+	Name string `json:"name" required:"true"`
+	// A map of user-defined configuration settings that will define
+	// how each associated datastore works. Each key/value pair is specific to a
+	// datastore type.
+	Values map[string]interface{} `json:"values" required:"true"`
+	// Associates the configuration group with a particular datastore.
+	Datastore *DatastoreOpts `json:"datastore,omitempty"`
+	// A human-readable explanation for the group.
+	Description string `json:"description,omitempty"`
+}
+
+// ToConfigCreateMap casts a CreateOpts struct into a JSON map.
+func (opts CreateOpts) ToConfigCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "configuration")
+}
+
+// Create will create a new configuration group.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToConfigCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(baseURL(client), &b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+	return
+}
+
+// Get will retrieve the details for a specified configuration group.
+func Get(client *gophercloud.ServiceClient, configID string) (r GetResult) {
+	_, r.Err = client.Get(resourceURL(client, configID), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the top-level interface for casting update options into
+// JSON maps.
+type UpdateOptsBuilder interface {
+	ToConfigUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts is the struct responsible for modifying existing configurations.
+type UpdateOpts struct {
+	// The configuration group name
+	Name string `json:"name,omitempty"`
+	// A map of user-defined configuration settings that will define
+	// how each associated datastore works. Each key/value pair is specific to a
+	// datastore type.
+	Values map[string]interface{} `json:"values,omitempty"`
+	// Associates the configuration group with a particular datastore.
+	Datastore *DatastoreOpts `json:"datastore,omitempty"`
+	// A human-readable explanation for the group.
+	Description string `json:"description,omitempty"`
+}
+
+// ToConfigUpdateMap will cast an UpdateOpts struct into a JSON map.
+func (opts UpdateOpts) ToConfigUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "configuration")
+}
+
+// Update will modify an existing configuration group by performing a merge
+// between new and existing values. If the key already exists, the new value
+// will overwrite. All other keys will remain unaffected.
+func Update(client *gophercloud.ServiceClient, configID string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToConfigUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Patch(resourceURL(client, configID), &b, nil, nil)
+	return
+}
+
+// Replace will modify an existing configuration group by overwriting the
+// entire parameter group with the new values provided. Any existing keys not
+// included in UpdateOptsBuilder will be deleted.
+func Replace(client *gophercloud.ServiceClient, configID string, opts UpdateOptsBuilder) (r ReplaceResult) {
+	b, err := opts.ToConfigUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(resourceURL(client, configID), &b, nil, nil)
+	return
+}
+
+// Delete will permanently delete a configuration group. Please note that
+// config groups cannot be deleted whilst still attached to running instances -
+// you must detach and then delete them.
+func Delete(client *gophercloud.ServiceClient, configID string) (r DeleteResult) {
+	_, r.Err = client.Delete(resourceURL(client, configID), nil)
+	return
+}
+
+// ListInstances will list all the instances associated with a particular
+// configuration group.
+func ListInstances(client *gophercloud.ServiceClient, configID string) pagination.Pager {
+	return pagination.NewPager(client, instancesURL(client, configID), func(r pagination.PageResult) pagination.Page {
+		return instances.InstancePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// ListDatastoreParams will list all the available and supported parameters
+// that can be used for a particular datastore ID and a particular version.
+// For example, if you are wondering how you can configure a MySQL 5.6 instance,
+// you can use this operation (you will need to retrieve the MySQL datastore ID
+// by using the datastores API).
+func ListDatastoreParams(client *gophercloud.ServiceClient, datastoreID, versionID string) pagination.Pager {
+	return pagination.NewPager(client, listDSParamsURL(client, datastoreID, versionID), func(r pagination.PageResult) pagination.Page {
+		return ParamPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// GetDatastoreParam will retrieve information about a specific configuration
+// parameter. For example, you can use this operation to understand more about
+// "innodb_file_per_table" configuration param for MySQL datastores. You will
+// need the param's ID first, which can be attained by using the ListDatastoreParams
+// operation.
+func GetDatastoreParam(client *gophercloud.ServiceClient, datastoreID, versionID, paramID string) (r ParamResult) {
+	_, r.Err = client.Get(getDSParamURL(client, datastoreID, versionID, paramID), &r.Body, nil)
+	return
+}
+
+// ListGlobalParams is similar to ListDatastoreParams but does not require a
+// DatastoreID.
+func ListGlobalParams(client *gophercloud.ServiceClient, versionID string) pagination.Pager {
+	return pagination.NewPager(client, listGlobalParamsURL(client, versionID), func(r pagination.PageResult) pagination.Page {
+		return ParamPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// GetGlobalParam is similar to GetDatastoreParam but does not require a
+// DatastoreID.
+func GetGlobalParam(client *gophercloud.ServiceClient, versionID, paramID string) (r ParamResult) {
+	_, r.Err = client.Get(getGlobalParamURL(client, versionID, paramID), &r.Body, nil)
+	return
+}
diff --git a/openstack/db/v1/configurations/requests_test.go b/openstack/db/v1/configurations/requests_test.go
new file mode 100644
index 0000000..575cf4f
--- /dev/null
+++ b/openstack/db/v1/configurations/requests_test.go
@@ -0,0 +1,236 @@
+package configurations
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/openstack/db/v1/instances"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+var (
+	configID = "{configID}"
+	_baseURL = "/configurations"
+	resURL   = _baseURL + "/" + configID
+
+	dsID               = "{datastoreID}"
+	versionID          = "{versionID}"
+	paramID            = "{paramID}"
+	dsParamListURL     = "/datastores/" + dsID + "/versions/" + versionID + "/parameters"
+	dsParamGetURL      = "/datastores/" + dsID + "/versions/" + versionID + "/parameters/" + paramID
+	globalParamListURL = "/datastores/versions/" + versionID + "/parameters"
+	globalParamGetURL  = "/datastores/versions/" + versionID + "/parameters/" + paramID
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, _baseURL, "GET", "", ListConfigsJSON, 200)
+
+	count := 0
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractConfigs(page)
+		th.AssertNoErr(t, err)
+
+		expected := []Config{ExampleConfig}
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertEquals(t, 1, count)
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, resURL, "GET", "", GetConfigJSON, 200)
+
+	config, err := Get(fake.ServiceClient(), configID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &ExampleConfig, config)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, _baseURL, "POST", CreateReq, CreateConfigJSON, 200)
+
+	opts := CreateOpts{
+		Datastore: &DatastoreOpts{
+			Type:    "a00000a0-00a0-0a00-00a0-000a000000aa",
+			Version: "b00000b0-00b0-0b00-00b0-000b000000bb",
+		},
+		Description: "example description",
+		Name:        "example-configuration-name",
+		Values: map[string]interface{}{
+			"collation_server": "latin1_swedish_ci",
+			"connect_timeout":  120,
+		},
+	}
+
+	config, err := Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &ExampleConfigWithValues, config)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, resURL, "PATCH", UpdateReq, "", 200)
+
+	opts := UpdateOpts{
+		Values: map[string]interface{}{
+			"connect_timeout": 300,
+		},
+	}
+
+	err := Update(fake.ServiceClient(), configID, opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestReplace(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, resURL, "PUT", UpdateReq, "", 202)
+
+	opts := UpdateOpts{
+		Values: map[string]interface{}{
+			"connect_timeout": 300,
+		},
+	}
+
+	err := Replace(fake.ServiceClient(), configID, opts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+
+	err := Delete(fake.ServiceClient(), configID).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestListInstances(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, resURL+"/instances", "GET", "", ListInstancesJSON, 200)
+
+	expectedInstance := instances.Instance{
+		ID:   "d4603f69-ec7e-4e9b-803f-600b9205576f",
+		Name: "json_rack_instance",
+	}
+
+	pages := 0
+	err := ListInstances(fake.ServiceClient(), configID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := instances.ExtractInstances(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.AssertDeepEquals(t, actual, []instances.Instance{expectedInstance})
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestListDSParams(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, dsParamListURL, "GET", "", ListParamsJSON, 200)
+
+	pages := 0
+	err := ListDatastoreParams(fake.ServiceClient(), dsID, versionID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractParams(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Param{
+			Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+			Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+			Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+			Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+		}
+
+		th.AssertDeepEquals(t, actual, expected)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGetDSParam(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, dsParamGetURL, "GET", "", GetParamJSON, 200)
+
+	param, err := GetDatastoreParam(fake.ServiceClient(), dsID, versionID, paramID).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Param{
+		Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+	}
+
+	th.AssertDeepEquals(t, expected, param)
+}
+
+func TestListGlobalParams(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, globalParamListURL, "GET", "", ListParamsJSON, 200)
+
+	pages := 0
+	err := ListGlobalParams(fake.ServiceClient(), versionID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractParams(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Param{
+			Param{Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer"},
+			Param{Max: 4294967296, Min: 0, Name: "key_buffer_size", RestartRequired: false, Type: "integer"},
+			Param{Max: 65535, Min: 2, Name: "connect_timeout", RestartRequired: false, Type: "integer"},
+			Param{Max: 4294967296, Min: 0, Name: "join_buffer_size", RestartRequired: false, Type: "integer"},
+		}
+
+		th.AssertDeepEquals(t, actual, expected)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGetGlobalParam(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, globalParamGetURL, "GET", "", GetParamJSON, 200)
+
+	param, err := GetGlobalParam(fake.ServiceClient(), versionID, paramID).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Param{
+		Max: 1, Min: 0, Name: "innodb_file_per_table", RestartRequired: true, Type: "integer",
+	}
+
+	th.AssertDeepEquals(t, expected, param)
+}
diff --git a/openstack/db/v1/configurations/results.go b/openstack/db/v1/configurations/results.go
new file mode 100644
index 0000000..c52a654
--- /dev/null
+++ b/openstack/db/v1/configurations/results.go
@@ -0,0 +1,121 @@
+package configurations
+
+import (
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Config represents a configuration group API resource.
+type Config struct {
+	Created              time.Time `json:"created"`
+	Updated              time.Time `json:"updated"`
+	DatastoreName        string    `json:"datastore_name"`
+	DatastoreVersionID   string    `json:"datastore_version_id"`
+	DatastoreVersionName string    `json:"datastore_version_name"`
+	Description          string
+	ID                   string
+	Name                 string
+	Values               map[string]interface{}
+}
+
+// ConfigPage contains a page of Config resources in a paginated collection.
+type ConfigPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a ConfigPage is empty.
+func (r ConfigPage) IsEmpty() (bool, error) {
+	is, err := ExtractConfigs(r)
+	return len(is) == 0, err
+}
+
+// ExtractConfigs will retrieve a slice of Config structs from a page.
+func ExtractConfigs(r pagination.Page) ([]Config, error) {
+	var s struct {
+		Configs []Config `json:"configurations"`
+	}
+	err := (r.(ConfigPage)).ExtractInto(&s)
+	return s.Configs, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract will retrieve a Config resource from an operation result.
+func (r commonResult) Extract() (*Config, error) {
+	var s struct {
+		Config *Config `json:"configuration"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Config, err
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	commonResult
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an Update operation.
+type UpdateResult struct {
+	gophercloud.ErrResult
+}
+
+// ReplaceResult represents the result of a Replace operation.
+type ReplaceResult struct {
+	gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Param represents a configuration parameter API resource.
+type Param struct {
+	Max             float64
+	Min             float64
+	Name            string
+	RestartRequired bool `json:"restart_required"`
+	Type            string
+}
+
+// ParamPage contains a page of Param resources in a paginated collection.
+type ParamPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a ParamPage is empty.
+func (r ParamPage) IsEmpty() (bool, error) {
+	is, err := ExtractParams(r)
+	return len(is) == 0, err
+}
+
+// ExtractParams will retrieve a slice of Param structs from a page.
+func ExtractParams(r pagination.Page) ([]Param, error) {
+	var s struct {
+		Params []Param `json:"configuration-parameters"`
+	}
+	err := (r.(ParamPage)).ExtractInto(&s)
+	return s.Params, err
+}
+
+// ParamResult represents the result of an operation which retrieves details
+// about a particular configuration param.
+type ParamResult struct {
+	gophercloud.Result
+}
+
+// Extract will retrieve a param from an operation result.
+func (r ParamResult) Extract() (*Param, error) {
+	var s *Param
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/db/v1/configurations/urls.go b/openstack/db/v1/configurations/urls.go
new file mode 100644
index 0000000..0a69253
--- /dev/null
+++ b/openstack/db/v1/configurations/urls.go
@@ -0,0 +1,31 @@
+package configurations
+
+import "github.com/gophercloud/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("configurations")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, configID string) string {
+	return c.ServiceURL("configurations", configID)
+}
+
+func instancesURL(c *gophercloud.ServiceClient, configID string) string {
+	return c.ServiceURL("configurations", configID, "instances")
+}
+
+func listDSParamsURL(c *gophercloud.ServiceClient, datastoreID, versionID string) string {
+	return c.ServiceURL("datastores", datastoreID, "versions", versionID, "parameters")
+}
+
+func getDSParamURL(c *gophercloud.ServiceClient, datastoreID, versionID, paramID string) string {
+	return c.ServiceURL("datastores", datastoreID, "versions", versionID, "parameters", paramID)
+}
+
+func listGlobalParamsURL(c *gophercloud.ServiceClient, versionID string) string {
+	return c.ServiceURL("datastores", "versions", versionID, "parameters")
+}
+
+func getGlobalParamURL(c *gophercloud.ServiceClient, versionID, paramID string) string {
+	return c.ServiceURL("datastores", "versions", versionID, "parameters", paramID)
+}
diff --git a/openstack/db/v1/databases/doc.go b/openstack/db/v1/databases/doc.go
new file mode 100644
index 0000000..15275fe
--- /dev/null
+++ b/openstack/db/v1/databases/doc.go
@@ -0,0 +1,6 @@
+// Package flavors provides information and interaction with the database API
+// resource in the OpenStack Database service.
+//
+// A database, when referred to here, refers to the database engine running on
+// an instance.
+package databases
diff --git a/openstack/db/v1/databases/fixtures.go b/openstack/db/v1/databases/fixtures.go
new file mode 100644
index 0000000..4b35062
--- /dev/null
+++ b/openstack/db/v1/databases/fixtures.go
@@ -0,0 +1,61 @@
+package databases
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+var (
+	instanceID = "{instanceID}"
+	resURL     = "/instances/" + instanceID + "/databases"
+)
+
+var createDBsReq = `
+{
+	"databases": [
+		{
+			"character_set": "utf8",
+			"collate": "utf8_general_ci",
+			"name": "testingdb"
+		},
+		{
+			"name": "sampledb"
+		}
+	]
+}
+`
+
+var listDBsResp = `
+{
+	"databases": [
+		{
+			"name": "anotherexampledb"
+		},
+		{
+			"name": "exampledb"
+		},
+		{
+			"name": "nextround"
+		},
+		{
+			"name": "sampledb"
+		},
+		{
+			"name": "testingdb"
+		}
+	]
+}
+`
+
+func HandleCreate(t *testing.T) {
+	fixture.SetupHandler(t, resURL, "POST", createDBsReq, "", 202)
+}
+
+func HandleList(t *testing.T) {
+	fixture.SetupHandler(t, resURL, "GET", "", listDBsResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+	fixture.SetupHandler(t, resURL+"/{dbName}", "DELETE", "", "", 202)
+}
diff --git a/openstack/db/v1/databases/requests.go b/openstack/db/v1/databases/requests.go
new file mode 100644
index 0000000..ef5394f
--- /dev/null
+++ b/openstack/db/v1/databases/requests.go
@@ -0,0 +1,89 @@
+package databases
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder builds create options
+type CreateOptsBuilder interface {
+	ToDBCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the struct responsible for configuring a database; often in
+// the context of an instance.
+type CreateOpts struct {
+	// Specifies the name of the database. Valid names can be composed
+	// of the following characters: letters (either case); numbers; these
+	// characters '@', '?', '#', ' ' but NEVER beginning a name string; '_' is
+	// permitted anywhere. Prohibited characters that are forbidden include:
+	// single quotes, double quotes, back quotes, semicolons, commas, backslashes,
+	// and forward slashes.
+	Name string `json:"name" required:"true"`
+	// Set of symbols and encodings. The default character set is
+	// "utf8". See http://dev.mysql.com/doc/refman/5.1/en/charset-mysql.html for
+	// supported character sets.
+	CharSet string `json:"character_set,omitempty"`
+	// Set of rules for comparing characters in a character set. The
+	// default value for collate is "utf8_general_ci". See
+	// http://dev.mysql.com/doc/refman/5.1/en/charset-mysql.html for supported
+	// collations.
+	Collate string `json:"collate,omitempty"`
+}
+
+// ToMap is a helper function to convert individual DB create opt structures
+// into sub-maps.
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+	if len(opts.Name) > 64 {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "databases.CreateOpts.Name"
+		err.Value = opts.Name
+		err.Info = "Must be less than 64 chars long"
+		return nil, err
+	}
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// BatchCreateOpts allows for multiple databases to created and modified.
+type BatchCreateOpts []CreateOpts
+
+// ToDBCreateMap renders a JSON map for creating DBs.
+func (opts BatchCreateOpts) ToDBCreateMap() (map[string]interface{}, error) {
+	dbs := make([]map[string]interface{}, len(opts))
+	for i, db := range opts {
+		dbMap, err := db.ToMap()
+		if err != nil {
+			return nil, err
+		}
+		dbs[i] = dbMap
+	}
+	return map[string]interface{}{"databases": dbs}, nil
+}
+
+// Create will create a new database within the specified instance. If the
+// specified instance does not exist, a 404 error will be returned.
+func Create(client *gophercloud.ServiceClient, instanceID string, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToDBCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(baseURL(client, instanceID), &b, nil, nil)
+	return
+}
+
+// List will list all of the databases for a specified instance. Note: this
+// operation will only return user-defined databases; it will exclude system
+// databases like "mysql", "information_schema", "lost+found" etc.
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+	return pagination.NewPager(client, baseURL(client, instanceID), func(r pagination.PageResult) pagination.Page {
+		return DBPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Delete will permanently delete the database within a specified instance.
+// All contained data inside the database will also be permanently deleted.
+func Delete(client *gophercloud.ServiceClient, instanceID, dbName string) (r DeleteResult) {
+	_, r.Err = client.Delete(dbURL(client, instanceID, dbName), nil)
+	return
+}
diff --git a/openstack/db/v1/databases/requests_test.go b/openstack/db/v1/databases/requests_test.go
new file mode 100644
index 0000000..5ec45e1
--- /dev/null
+++ b/openstack/db/v1/databases/requests_test.go
@@ -0,0 +1,66 @@
+package databases
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreate(t)
+
+	opts := BatchCreateOpts{
+		CreateOpts{Name: "testingdb", CharSet: "utf8", Collate: "utf8_general_ci"},
+		CreateOpts{Name: "sampledb"},
+	}
+
+	res := Create(fake.ServiceClient(), instanceID, opts)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleList(t)
+
+	expectedDBs := []Database{
+		Database{Name: "anotherexampledb"},
+		Database{Name: "exampledb"},
+		Database{Name: "nextround"},
+		Database{Name: "sampledb"},
+		Database{Name: "testingdb"},
+	}
+
+	pages := 0
+	err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractDBs(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, expectedDBs, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+
+	if pages != 1 {
+		t.Errorf("Expected 1 page, saw %d", pages)
+	}
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDelete(t)
+
+	err := Delete(fake.ServiceClient(), instanceID, "{dbName}").ExtractErr()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/db/v1/databases/results.go b/openstack/db/v1/databases/results.go
new file mode 100644
index 0000000..0479d0e
--- /dev/null
+++ b/openstack/db/v1/databases/results.go
@@ -0,0 +1,63 @@
+package databases
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Database represents a Database API resource.
+type Database struct {
+	// Specifies the name of the MySQL database.
+	Name string
+
+	// Set of symbols and encodings. The default character set is utf8.
+	CharSet string
+
+	// Set of rules for comparing characters in a character set. The default
+	// value for collate is utf8_general_ci.
+	Collate string
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+	gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// DBPage represents a single page of a paginated DB collection.
+type DBPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page DBPage) IsEmpty() (bool, error) {
+	dbs, err := ExtractDBs(page)
+	return len(dbs) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page DBPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"databases_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractDBs will convert a generic pagination struct into a more
+// relevant slice of DB structs.
+func ExtractDBs(page pagination.Page) ([]Database, error) {
+	r := page.(DBPage)
+	var s struct {
+		Databases []Database `json:"databases"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Databases, err
+}
diff --git a/openstack/db/v1/databases/urls.go b/openstack/db/v1/databases/urls.go
new file mode 100644
index 0000000..aba42c9
--- /dev/null
+++ b/openstack/db/v1/databases/urls.go
@@ -0,0 +1,11 @@
+package databases
+
+import "github.com/gophercloud/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+	return c.ServiceURL("instances", instanceID, "databases")
+}
+
+func dbURL(c *gophercloud.ServiceClient, instanceID, dbName string) string {
+	return c.ServiceURL("instances", instanceID, "databases", dbName)
+}
diff --git a/openstack/db/v1/datastores/doc.go b/openstack/db/v1/datastores/doc.go
new file mode 100644
index 0000000..ae14026
--- /dev/null
+++ b/openstack/db/v1/datastores/doc.go
@@ -0,0 +1,3 @@
+// Package datastores provides information and interaction with the datastore
+// API resource in the Rackspace Database service.
+package datastores
diff --git a/openstack/db/v1/datastores/fixtures.go b/openstack/db/v1/datastores/fixtures.go
new file mode 100644
index 0000000..837b1f4
--- /dev/null
+++ b/openstack/db/v1/datastores/fixtures.go
@@ -0,0 +1,100 @@
+package datastores
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+const version1JSON = `
+{
+	"id": "b00000b0-00b0-0b00-00b0-000b000000bb",
+	"links": [
+		{
+			"href": "https://10.240.28.38:8779/v1.0/1234/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb",
+			"rel": "self"
+		},
+		{
+			"href": "https://10.240.28.38:8779/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb",
+			"rel": "bookmark"
+		}
+	],
+	"name": "5.1"
+}
+`
+
+const version2JSON = `
+{
+	"id": "c00000b0-00c0-0c00-00c0-000b000000cc",
+	"links": [
+		{
+			"href": "https://10.240.28.38:8779/v1.0/1234/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc",
+			"rel": "self"
+		},
+		{
+			"href": "https://10.240.28.38:8779/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc",
+			"rel": "bookmark"
+		}
+	],
+	"name": "5.2"
+}
+`
+
+var versionsJSON = fmt.Sprintf(`"versions": [%s, %s]`, version1JSON, version2JSON)
+
+var singleDSJSON = fmt.Sprintf(`
+{
+  "default_version": "c00000b0-00c0-0c00-00c0-000b000000cc",
+  "id": "10000000-0000-0000-0000-000000000001",
+  "links": [
+    {
+      "href": "https://10.240.28.38:8779/v1.0/1234/datastores/10000000-0000-0000-0000-000000000001",
+      "rel": "self"
+    },
+    {
+      "href": "https://10.240.28.38:8779/datastores/10000000-0000-0000-0000-000000000001",
+      "rel": "bookmark"
+    }
+  ],
+  "name": "mysql",
+  %s
+}
+`, versionsJSON)
+
+var (
+	ListDSResp       = fmt.Sprintf(`{"datastores":[%s]}`, singleDSJSON)
+	GetDSResp        = fmt.Sprintf(`{"datastore":%s}`, singleDSJSON)
+	ListVersionsResp = fmt.Sprintf(`{%s}`, versionsJSON)
+	GetVersionResp   = fmt.Sprintf(`{"version":%s}`, version1JSON)
+)
+
+var ExampleVersion1 = Version{
+	ID: "b00000b0-00b0-0b00-00b0-000b000000bb",
+	Links: []gophercloud.Link{
+		gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+		gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/b00000b0-00b0-0b00-00b0-000b000000bb"},
+	},
+	Name: "5.1",
+}
+
+var exampleVersion2 = Version{
+	ID: "c00000b0-00c0-0c00-00c0-000b000000cc",
+	Links: []gophercloud.Link{
+		gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+		gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/versions/c00000b0-00c0-0c00-00c0-000b000000cc"},
+	},
+	Name: "5.2",
+}
+
+var ExampleVersions = []Version{ExampleVersion1, exampleVersion2}
+
+var ExampleDatastore = Datastore{
+	DefaultVersion: "c00000b0-00c0-0c00-00c0-000b000000cc",
+	ID:             "10000000-0000-0000-0000-000000000001",
+	Links: []gophercloud.Link{
+		gophercloud.Link{Rel: "self", Href: "https://10.240.28.38:8779/v1.0/1234/datastores/10000000-0000-0000-0000-000000000001"},
+		gophercloud.Link{Rel: "bookmark", Href: "https://10.240.28.38:8779/datastores/10000000-0000-0000-0000-000000000001"},
+	},
+	Name:     "mysql",
+	Versions: ExampleVersions,
+}
diff --git a/openstack/db/v1/datastores/requests.go b/openstack/db/v1/datastores/requests.go
new file mode 100644
index 0000000..134e309
--- /dev/null
+++ b/openstack/db/v1/datastores/requests.go
@@ -0,0 +1,33 @@
+package datastores
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will list all available datastore types that instances can use.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+		return DatastorePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get will retrieve the details of a specified datastore type.
+func Get(client *gophercloud.ServiceClient, datastoreID string) (r GetResult) {
+	_, r.Err = client.Get(resourceURL(client, datastoreID), &r.Body, nil)
+	return
+}
+
+// ListVersions will list all of the available versions for a specified
+// datastore type.
+func ListVersions(client *gophercloud.ServiceClient, datastoreID string) pagination.Pager {
+	return pagination.NewPager(client, versionsURL(client, datastoreID), func(r pagination.PageResult) pagination.Page {
+		return VersionPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// GetVersion will retrieve the details of a specified datastore version.
+func GetVersion(client *gophercloud.ServiceClient, datastoreID, versionID string) (r GetVersionResult) {
+	_, r.Err = client.Get(versionURL(client, datastoreID, versionID), &r.Body, nil)
+	return
+}
diff --git a/openstack/db/v1/datastores/requests_test.go b/openstack/db/v1/datastores/requests_test.go
new file mode 100644
index 0000000..07faf2c
--- /dev/null
+++ b/openstack/db/v1/datastores/requests_test.go
@@ -0,0 +1,78 @@
+package datastores
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, "/datastores", "GET", "", ListDSResp, 200)
+
+	pages := 0
+
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractDatastores(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, []Datastore{ExampleDatastore}, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, "/datastores/{dsID}", "GET", "", GetDSResp, 200)
+
+	ds, err := Get(fake.ServiceClient(), "{dsID}").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &ExampleDatastore, ds)
+}
+
+func TestListVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, "/datastores/{dsID}/versions", "GET", "", ListVersionsResp, 200)
+
+	pages := 0
+
+	err := ListVersions(fake.ServiceClient(), "{dsID}").EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractVersions(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, ExampleVersions, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGetVersion(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	fixture.SetupHandler(t, "/datastores/{dsID}/versions/{versionID}", "GET", "", GetVersionResp, 200)
+
+	ds, err := GetVersion(fake.ServiceClient(), "{dsID}", "{versionID}").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &ExampleVersion1, ds)
+}
diff --git a/openstack/db/v1/datastores/results.go b/openstack/db/v1/datastores/results.go
new file mode 100644
index 0000000..a6e27d2
--- /dev/null
+++ b/openstack/db/v1/datastores/results.go
@@ -0,0 +1,100 @@
+package datastores
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Version represents a version API resource. Multiple versions belong to a Datastore.
+type Version struct {
+	ID    string
+	Links []gophercloud.Link
+	Name  string
+}
+
+// Datastore represents a Datastore API resource.
+type Datastore struct {
+	DefaultVersion string `json:"default_version"`
+	ID             string
+	Links          []gophercloud.Link
+	Name           string
+	Versions       []Version
+}
+
+// DatastorePartial is a meta structure which is used in various API responses.
+// It is a lightweight and truncated version of a full Datastore resource,
+// offering details of the Version, Type and VersionID only.
+type DatastorePartial struct {
+	Version   string
+	Type      string
+	VersionID string `json:"version_id"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// GetVersionResult represents the result of getting a version.
+type GetVersionResult struct {
+	gophercloud.Result
+}
+
+// DatastorePage represents a page of datastore resources.
+type DatastorePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a Datastore collection is empty.
+func (r DatastorePage) IsEmpty() (bool, error) {
+	is, err := ExtractDatastores(r)
+	return len(is) == 0, err
+}
+
+// ExtractDatastores retrieves a slice of datastore structs from a paginated
+// collection.
+func ExtractDatastores(r pagination.Page) ([]Datastore, error) {
+	var s struct {
+		Datastores []Datastore `json:"datastores"`
+	}
+	err := (r.(DatastorePage)).ExtractInto(&s)
+	return s.Datastores, err
+}
+
+// Extract retrieves a single Datastore struct from an operation result.
+func (r GetResult) Extract() (*Datastore, error) {
+	var s struct {
+		Datastore *Datastore `json:"datastore"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Datastore, err
+}
+
+// VersionPage represents a page of version resources.
+type VersionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty indicates whether a collection of version resources is empty.
+func (r VersionPage) IsEmpty() (bool, error) {
+	is, err := ExtractVersions(r)
+	return len(is) == 0, err
+}
+
+// ExtractVersions retrieves a slice of versions from a paginated collection.
+func ExtractVersions(r pagination.Page) ([]Version, error) {
+	var s struct {
+		Versions []Version `json:"versions"`
+	}
+	err := (r.(VersionPage)).ExtractInto(&s)
+	return s.Versions, err
+}
+
+// Extract retrieves a single Version struct from an operation result.
+func (r GetVersionResult) Extract() (*Version, error) {
+	var s struct {
+		Version *Version `json:"version"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Version, err
+}
diff --git a/openstack/db/v1/datastores/urls.go b/openstack/db/v1/datastores/urls.go
new file mode 100644
index 0000000..06d1b3d
--- /dev/null
+++ b/openstack/db/v1/datastores/urls.go
@@ -0,0 +1,19 @@
+package datastores
+
+import "github.com/gophercloud/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("datastores")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, dsID string) string {
+	return c.ServiceURL("datastores", dsID)
+}
+
+func versionsURL(c *gophercloud.ServiceClient, dsID string) string {
+	return c.ServiceURL("datastores", dsID, "versions")
+}
+
+func versionURL(c *gophercloud.ServiceClient, dsID, versionID string) string {
+	return c.ServiceURL("datastores", dsID, "versions", versionID)
+}
diff --git a/openstack/db/v1/flavors/doc.go b/openstack/db/v1/flavors/doc.go
new file mode 100644
index 0000000..4d281d5
--- /dev/null
+++ b/openstack/db/v1/flavors/doc.go
@@ -0,0 +1,7 @@
+// Package flavors provides information and interaction with the flavor API
+// resource in the OpenStack Database service.
+//
+// A flavor is an available hardware configuration for a database instance.
+// Each flavor has a unique combination of disk space, memory capacity and
+// priority for CPU time.
+package flavors
diff --git a/openstack/db/v1/flavors/fixtures.go b/openstack/db/v1/flavors/fixtures.go
new file mode 100644
index 0000000..6a013f9
--- /dev/null
+++ b/openstack/db/v1/flavors/fixtures.go
@@ -0,0 +1,50 @@
+package flavors
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+const flavor = `
+{
+	"id": %d,
+	"links": [
+		{
+			"href": "https://openstack.example.com/v1.0/1234/flavors/%d",
+			"rel": "self"
+		},
+		{
+			"href": "https://openstack.example.com/flavors/%d",
+			"rel": "bookmark"
+		}
+	],
+	"name": "%s",
+	"ram": %d
+}
+`
+
+var (
+	flavorID = "{flavorID}"
+	_baseURL = "/flavors"
+	resURL   = "/flavors/" + flavorID
+)
+
+var (
+	flavor1 = fmt.Sprintf(flavor, 1, 1, 1, "m1.tiny", 512)
+	flavor2 = fmt.Sprintf(flavor, 2, 2, 2, "m1.small", 1024)
+	flavor3 = fmt.Sprintf(flavor, 3, 3, 3, "m1.medium", 2048)
+	flavor4 = fmt.Sprintf(flavor, 4, 4, 4, "m1.large", 4096)
+
+	listFlavorsResp = fmt.Sprintf(`{"flavors":[%s, %s, %s, %s]}`, flavor1, flavor2, flavor3, flavor4)
+	getFlavorResp   = fmt.Sprintf(`{"flavor": %s}`, flavor1)
+)
+
+func HandleList(t *testing.T) {
+	fixture.SetupHandler(t, _baseURL, "GET", "", listFlavorsResp, 200)
+}
+
+func HandleGet(t *testing.T) {
+	fixture.SetupHandler(t, resURL, "GET", "", getFlavorResp, 200)
+}
diff --git a/openstack/db/v1/flavors/requests.go b/openstack/db/v1/flavors/requests.go
new file mode 100644
index 0000000..7fac56a
--- /dev/null
+++ b/openstack/db/v1/flavors/requests.go
@@ -0,0 +1,21 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List will list all available hardware flavors that an instance can use. The
+// operation is identical to the one supported by the Nova API, but without the
+// "disk" property.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listURL(client), func(r pagination.PageResult) pagination.Page {
+		return FlavorPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get will retrieve information for a specified hardware flavor.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+	return
+}
diff --git a/openstack/db/v1/flavors/requests_test.go b/openstack/db/v1/flavors/requests_test.go
new file mode 100644
index 0000000..ce01759
--- /dev/null
+++ b/openstack/db/v1/flavors/requests_test.go
@@ -0,0 +1,91 @@
+package flavors
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListFlavors(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleList(t)
+
+	pages := 0
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractFlavors(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Flavor{
+			Flavor{
+				ID:   1,
+				Name: "m1.tiny",
+				RAM:  512,
+				Links: []gophercloud.Link{
+					gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+					gophercloud.Link{Href: "https://openstack.example.com/flavors/1", Rel: "bookmark"},
+				},
+			},
+			Flavor{
+				ID:   2,
+				Name: "m1.small",
+				RAM:  1024,
+				Links: []gophercloud.Link{
+					gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/2", Rel: "self"},
+					gophercloud.Link{Href: "https://openstack.example.com/flavors/2", Rel: "bookmark"},
+				},
+			},
+			Flavor{
+				ID:   3,
+				Name: "m1.medium",
+				RAM:  2048,
+				Links: []gophercloud.Link{
+					gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/3", Rel: "self"},
+					gophercloud.Link{Href: "https://openstack.example.com/flavors/3", Rel: "bookmark"},
+				},
+			},
+			Flavor{
+				ID:   4,
+				Name: "m1.large",
+				RAM:  4096,
+				Links: []gophercloud.Link{
+					gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/4", Rel: "self"},
+					gophercloud.Link{Href: "https://openstack.example.com/flavors/4", Rel: "bookmark"},
+				},
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGetFlavor(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGet(t)
+
+	actual, err := Get(fake.ServiceClient(), flavorID).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Flavor{
+		ID:   1,
+		Name: "m1.tiny",
+		RAM:  512,
+		Links: []gophercloud.Link{
+			gophercloud.Link{Href: "https://openstack.example.com/v1.0/1234/flavors/1", Rel: "self"},
+		},
+	}
+
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/db/v1/flavors/results.go b/openstack/db/v1/flavors/results.go
new file mode 100644
index 0000000..f74f20c
--- /dev/null
+++ b/openstack/db/v1/flavors/results.go
@@ -0,0 +1,67 @@
+package flavors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// GetResult temporarily holds the response from a Get call.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract provides access to the individual Flavor returned by the Get function.
+func (r GetResult) Extract() (*Flavor, error) {
+	var s struct {
+		Flavor *Flavor `json:"flavor"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Flavor, err
+}
+
+// Flavor records represent (virtual) hardware configurations for server resources in a region.
+type Flavor struct {
+	// The flavor's unique identifier.
+	ID int `json:"id"`
+
+	// The RAM capacity for the flavor.
+	RAM int `json:"ram"`
+
+	// The Name field provides a human-readable moniker for the flavor.
+	Name string `json:"name"`
+
+	// Links to access the flavor.
+	Links []gophercloud.Link
+}
+
+// FlavorPage contains a single page of the response from a List call.
+type FlavorPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty determines if a page contains any results.
+func (page FlavorPage) IsEmpty() (bool, error) {
+	flavors, err := ExtractFlavors(page)
+	return len(flavors) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (page FlavorPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"flavors_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractFlavors provides access to the list of flavors in a page acquired from the List operation.
+func ExtractFlavors(r pagination.Page) ([]Flavor, error) {
+	var s struct {
+		Flavors []Flavor `json:"flavors"`
+	}
+	err := (r.(FlavorPage)).ExtractInto(&s)
+	return s.Flavors, err
+}
diff --git a/openstack/db/v1/flavors/urls.go b/openstack/db/v1/flavors/urls.go
new file mode 100644
index 0000000..a24301b
--- /dev/null
+++ b/openstack/db/v1/flavors/urls.go
@@ -0,0 +1,11 @@
+package flavors
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(client *gophercloud.ServiceClient, id string) string {
+	return client.ServiceURL("flavors", id)
+}
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("flavors")
+}
diff --git a/openstack/db/v1/instances/doc.go b/openstack/db/v1/instances/doc.go
new file mode 100644
index 0000000..dc5c90f
--- /dev/null
+++ b/openstack/db/v1/instances/doc.go
@@ -0,0 +1,7 @@
+// Package instances provides information and interaction with the instance API
+// resource in the OpenStack Database service.
+//
+// A database instance is an isolated database environment with compute and
+// storage resources in a single tenant environment on a shared physical host
+// machine.
+package instances
diff --git a/openstack/db/v1/instances/fixtures.go b/openstack/db/v1/instances/fixtures.go
new file mode 100644
index 0000000..d0a3856
--- /dev/null
+++ b/openstack/db/v1/instances/fixtures.go
@@ -0,0 +1,169 @@
+package instances
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/flavors"
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+var (
+	timestamp  = "2015-11-12T14:22:42Z"
+	timeVal, _ = time.Parse(time.RFC3339, timestamp)
+)
+
+var instance = `
+{
+  "created": "` + timestamp + `",
+  "datastore": {
+    "type": "mysql",
+    "version": "5.6"
+  },
+  "flavor": {
+    "id": 1,
+    "links": [
+      {
+        "href": "https://my-openstack.com/v1.0/1234/flavors/1",
+        "rel": "self"
+      },
+      {
+        "href": "https://my-openstack.com/v1.0/1234/flavors/1",
+        "rel": "bookmark"
+      }
+    ]
+  },
+  "links": [
+    {
+      "href": "https://my-openstack.com/v1.0/1234/instances/1",
+      "rel": "self"
+    }
+  ],
+  "hostname": "e09ad9a3f73309469cf1f43d11e79549caf9acf2.my-openstack.com",
+  "id": "{instanceID}",
+  "name": "json_rack_instance",
+  "status": "BUILD",
+  "updated": "` + timestamp + `",
+  "volume": {
+    "size": 2
+  }
+}
+`
+
+var createReq = `
+{
+	"instance": {
+		"databases": [
+			{
+				"character_set": "utf8",
+				"collate": "utf8_general_ci",
+				"name": "sampledb"
+			},
+			{
+				"name": "nextround"
+			}
+		],
+		"flavorRef": "1",
+		"name": "json_rack_instance",
+		"users": [
+			{
+				"databases": [
+					{
+						"name": "sampledb"
+					}
+				],
+				"name": "demouser",
+				"password": "demopassword"
+			}
+		],
+		"volume": {
+			"size": 2
+		}
+	}
+}
+`
+
+var (
+	instanceID = "{instanceID}"
+	rootURL    = "/instances"
+	resURL     = rootURL + "/" + instanceID
+	uRootURL   = resURL + "/root"
+	aURL       = resURL + "/action"
+)
+
+var (
+	restartReq   = `{"restart": {}}`
+	resizeReq    = `{"resize": {"flavorRef": "2"}}`
+	resizeVolReq = `{"resize": {"volume": {"size": 4}}}`
+)
+
+var (
+	createResp        = fmt.Sprintf(`{"instance": %s}`, instance)
+	listInstancesResp = fmt.Sprintf(`{"instances":[%s]}`, instance)
+	getInstanceResp   = createResp
+	enableUserResp    = `{"user":{"name":"root","password":"secretsecret"}}`
+	isUserEnabledResp = `{"rootEnabled":true}`
+)
+
+var expectedInstance = Instance{
+	Created: timeVal,
+	Updated: timeVal,
+	Flavor: flavors.Flavor{
+		ID: 1,
+		Links: []gophercloud.Link{
+			gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/flavors/1", Rel: "self"},
+			gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/flavors/1", Rel: "bookmark"},
+		},
+	},
+	Hostname: "e09ad9a3f73309469cf1f43d11e79549caf9acf2.my-openstack.com",
+	ID:       instanceID,
+	Links: []gophercloud.Link{
+		gophercloud.Link{Href: "https://my-openstack.com/v1.0/1234/instances/1", Rel: "self"},
+	},
+	Name:   "json_rack_instance",
+	Status: "BUILD",
+	Volume: Volume{Size: 2},
+	Datastore: datastores.DatastorePartial{
+		Type:    "mysql",
+		Version: "5.6",
+	},
+}
+
+func HandleCreate(t *testing.T) {
+	fixture.SetupHandler(t, rootURL, "POST", createReq, createResp, 200)
+}
+
+func HandleList(t *testing.T) {
+	fixture.SetupHandler(t, rootURL, "GET", "", listInstancesResp, 200)
+}
+
+func HandleGet(t *testing.T) {
+	fixture.SetupHandler(t, resURL, "GET", "", getInstanceResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+	fixture.SetupHandler(t, resURL, "DELETE", "", "", 202)
+}
+
+func HandleEnableRoot(t *testing.T) {
+	fixture.SetupHandler(t, uRootURL, "POST", "", enableUserResp, 200)
+}
+
+func HandleIsRootEnabled(t *testing.T) {
+	fixture.SetupHandler(t, uRootURL, "GET", "", isUserEnabledResp, 200)
+}
+
+func HandleRestart(t *testing.T) {
+	fixture.SetupHandler(t, aURL, "POST", restartReq, "", 202)
+}
+
+func HandleResize(t *testing.T) {
+	fixture.SetupHandler(t, aURL, "POST", resizeReq, "", 202)
+}
+
+func HandleResizeVol(t *testing.T) {
+	fixture.SetupHandler(t, aURL, "POST", resizeVolReq, "", 202)
+}
diff --git a/openstack/db/v1/instances/requests.go b/openstack/db/v1/instances/requests.go
new file mode 100644
index 0000000..4f06649
--- /dev/null
+++ b/openstack/db/v1/instances/requests.go
@@ -0,0 +1,169 @@
+package instances
+
+import (
+	"github.com/gophercloud/gophercloud"
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/users"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the top-level interface for create options.
+type CreateOptsBuilder interface {
+	ToInstanceCreateMap() (map[string]interface{}, error)
+}
+
+// DatastoreOpts represents the configuration for how an instance stores data.
+type DatastoreOpts struct {
+	Version string `json:"version"`
+	Type    string `json:"type"`
+}
+
+// ToMap converts a DatastoreOpts to a map[string]string (for a request body)
+func (opts DatastoreOpts) ToMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// CreateOpts is the struct responsible for configuring a new database instance.
+type CreateOpts struct {
+	// Either the integer UUID (in string form) of the flavor, or its URI
+	// reference as specified in the response from the List() call. Required.
+	FlavorRef string
+	// Specifies the volume size in gigabytes (GB). The value must be between 1
+	// and 300. Required.
+	Size int
+	// Name of the instance to create. The length of the name is limited to
+	// 255 characters and any characters are permitted. Optional.
+	Name string
+	// A slice of database information options.
+	Databases db.CreateOptsBuilder
+	// A slice of user information options.
+	Users users.CreateOptsBuilder
+	// Options to configure the type of datastore the instance will use. This is
+	// optional, and if excluded will default to MySQL.
+	Datastore *DatastoreOpts
+}
+
+// ToInstanceCreateMap will render a JSON map.
+func (opts CreateOpts) ToInstanceCreateMap() (map[string]interface{}, error) {
+	if opts.Size > 300 || opts.Size < 1 {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "instances.CreateOpts.Size"
+		err.Value = opts.Size
+		err.Info = "Size (GB) must be between 1-300"
+		return nil, err
+	}
+
+	if opts.FlavorRef == "" {
+		return nil, gophercloud.ErrMissingInput{Argument: "instances.CreateOpts.FlavorRef"}
+	}
+
+	instance := map[string]interface{}{
+		"volume":    map[string]int{"size": opts.Size},
+		"flavorRef": opts.FlavorRef,
+	}
+
+	if opts.Name != "" {
+		instance["name"] = opts.Name
+	}
+	if opts.Databases != nil {
+		dbs, err := opts.Databases.ToDBCreateMap()
+		if err != nil {
+			return nil, err
+		}
+		instance["databases"] = dbs["databases"]
+	}
+	if opts.Users != nil {
+		users, err := opts.Users.ToUserCreateMap()
+		if err != nil {
+			return nil, err
+		}
+		instance["users"] = users["users"]
+	}
+	if opts.Datastore != nil {
+		datastore, err := opts.Datastore.ToMap()
+		if err != nil {
+			return nil, err
+		}
+		instance["datastore"] = datastore
+	}
+
+	return map[string]interface{}{"instance": instance}, nil
+}
+
+// Create asynchronously provisions a new database instance. It requires the
+// user to specify a flavor and a volume size. The API service then provisions
+// the instance with the requested flavor and sets up a volume of the specified
+// size, which is the storage for the database instance.
+//
+// Although this call only allows the creation of 1 instance per request, you
+// can create an instance with multiple databases and users. The default
+// binding for a MySQL instance is port 3306.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToInstanceCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(baseURL(client), &b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+	return
+}
+
+// List retrieves the status and information for all database instances.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, baseURL(client), func(r pagination.PageResult) pagination.Page {
+		return InstancePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get retrieves the status and information for a specified database instance.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(resourceURL(client, id), &r.Body, nil)
+	return
+}
+
+// Delete permanently destroys the database instance.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(resourceURL(client, id), nil)
+	return
+}
+
+// EnableRootUser enables the login from any host for the root user and
+// provides the user with a generated root password.
+func EnableRootUser(client *gophercloud.ServiceClient, id string) (r EnableRootUserResult) {
+	_, r.Err = client.Post(userRootURL(client, id), nil, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{200}})
+	return
+}
+
+// IsRootEnabled checks an instance to see if root access is enabled. It returns
+// True if root user is enabled for the specified database instance or False
+// otherwise.
+func IsRootEnabled(client *gophercloud.ServiceClient, id string) (r IsRootEnabledResult) {
+	_, r.Err = client.Get(userRootURL(client, id), &r.Body, nil)
+	return
+}
+
+// Restart will restart only the MySQL Instance. Restarting MySQL will
+// erase any dynamic configuration settings that you have made within MySQL.
+// The MySQL service will be unavailable until the instance restarts.
+func Restart(client *gophercloud.ServiceClient, id string) (r ActionResult) {
+	b := map[string]interface{}{"restart": struct{}{}}
+	_, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+	return
+}
+
+// Resize changes the memory size of the instance, assuming a valid
+// flavorRef is provided. It will also restart the MySQL service.
+func Resize(client *gophercloud.ServiceClient, id, flavorRef string) (r ActionResult) {
+	b := map[string]interface{}{"resize": map[string]string{"flavorRef": flavorRef}}
+	_, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+	return
+}
+
+// ResizeVolume will resize the attached volume for an instance. It supports
+// only increasing the volume size and does not support decreasing the size.
+// The volume size is in gigabytes (GB) and must be an integer.
+func ResizeVolume(client *gophercloud.ServiceClient, id string, size int) (r ActionResult) {
+	b := map[string]interface{}{"resize": map[string]interface{}{"volume": map[string]int{"size": size}}}
+	_, r.Err = client.Post(actionURL(client, id), &b, nil, nil)
+	return
+}
diff --git a/openstack/db/v1/instances/requests_test.go b/openstack/db/v1/instances/requests_test.go
new file mode 100644
index 0000000..3caac23
--- /dev/null
+++ b/openstack/db/v1/instances/requests_test.go
@@ -0,0 +1,133 @@
+package instances
+
+import (
+	"testing"
+
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/users"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreate(t)
+
+	opts := CreateOpts{
+		Name:      "json_rack_instance",
+		FlavorRef: "1",
+		Databases: db.BatchCreateOpts{
+			db.CreateOpts{CharSet: "utf8", Collate: "utf8_general_ci", Name: "sampledb"},
+			db.CreateOpts{Name: "nextround"},
+		},
+		Users: users.BatchCreateOpts{
+			users.CreateOpts{
+				Name:     "demouser",
+				Password: "demopassword",
+				Databases: db.BatchCreateOpts{
+					db.CreateOpts{Name: "sampledb"},
+				},
+			},
+		},
+		Size: 2,
+	}
+
+	instance, err := Create(fake.ServiceClient(), opts).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &expectedInstance, instance)
+}
+
+func TestInstanceList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleList(t)
+
+	pages := 0
+	err := List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractInstances(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, []Instance{expectedInstance}, actual)
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestGetInstance(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGet(t)
+
+	instance, err := Get(fake.ServiceClient(), instanceID).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, &expectedInstance, instance)
+}
+
+func TestDeleteInstance(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDelete(t)
+
+	res := Delete(fake.ServiceClient(), instanceID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestEnableRootUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleEnableRoot(t)
+
+	expected := &users.User{Name: "root", Password: "secretsecret"}
+	user, err := EnableRootUser(fake.ServiceClient(), instanceID).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, user)
+}
+
+func TestIsRootEnabled(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleIsRootEnabled(t)
+
+	isEnabled, err := IsRootEnabled(fake.ServiceClient(), instanceID).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, true, isEnabled)
+}
+
+func TestRestart(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleRestart(t)
+
+	res := Restart(fake.ServiceClient(), instanceID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestResize(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleResize(t)
+
+	res := Resize(fake.ServiceClient(), instanceID, "2")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestResizeVolume(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleResizeVol(t)
+
+	res := ResizeVolume(fake.ServiceClient(), instanceID, 4)
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/instances/results.go b/openstack/db/v1/instances/results.go
new file mode 100644
index 0000000..21900d7
--- /dev/null
+++ b/openstack/db/v1/instances/results.go
@@ -0,0 +1,150 @@
+package instances
+
+import (
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/datastores"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/flavors"
+	"github.com/gophercloud/gophercloud/openstack/db/v1/users"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Volume represents information about an attached volume for a database instance.
+type Volume struct {
+	// The size in GB of the volume
+	Size int
+
+	Used float64
+}
+
+// Instance represents a remote MySQL instance.
+type Instance struct {
+	// Indicates the datetime that the instance was created
+	Created time.Time `json:"created"`
+
+	// Indicates the most recent datetime that the instance was updated.
+	Updated time.Time `json:"updated"`
+
+	// Indicates the hardware flavor the instance uses.
+	Flavor flavors.Flavor
+
+	// A DNS-resolvable hostname associated with the database instance (rather
+	// than an IPv4 address). Since the hostname always resolves to the correct
+	// IP address of the database instance, this relieves the user from the task
+	// of maintaining the mapping. Note that although the IP address may likely
+	// change on resizing, migrating, and so forth, the hostname always resolves
+	// to the correct database instance.
+	Hostname string
+
+	// Indicates the unique identifier for the instance resource.
+	ID string
+
+	// Exposes various links that reference the instance resource.
+	Links []gophercloud.Link
+
+	// The human-readable name of the instance.
+	Name string
+
+	// The build status of the instance.
+	Status string
+
+	// Information about the attached volume of the instance.
+	Volume Volume
+
+	// Indicates how the instance stores data.
+	Datastore datastores.DatastorePartial
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Extract will extract an Instance from various result structs.
+func (r commonResult) Extract() (*Instance, error) {
+	var s struct {
+		Instance *Instance `json:"instance"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Instance, err
+}
+
+// InstancePage represents a single page of a paginated instance collection.
+type InstancePage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page InstancePage) IsEmpty() (bool, error) {
+	instances, err := ExtractInstances(page)
+	return len(instances) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page InstancePage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"instances_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractInstances will convert a generic pagination struct into a more
+// relevant slice of Instance structs.
+func ExtractInstances(r pagination.Page) ([]Instance, error) {
+	var s struct {
+		Instances []Instance `json:"instances"`
+	}
+	err := (r.(InstancePage)).ExtractInto(&s)
+	return s.Instances, err
+}
+
+// EnableRootUserResult represents the result of an operation to enable the root user.
+type EnableRootUserResult struct {
+	gophercloud.Result
+}
+
+// Extract will extract root user information from a UserRootResult.
+func (r EnableRootUserResult) Extract() (*users.User, error) {
+	var s struct {
+		User *users.User `json:"user"`
+	}
+	err := r.ExtractInto(&s)
+	return s.User, err
+}
+
+// ActionResult represents the result of action requests, such as: restarting
+// an instance service, resizing its memory allocation, and resizing its
+// attached volume size.
+type ActionResult struct {
+	gophercloud.ErrResult
+}
+
+// IsRootEnabledResult is the result of a call to IsRootEnabled. To see if
+// root is enabled, call the type's Extract method.
+type IsRootEnabledResult struct {
+	gophercloud.Result
+}
+
+// Extract is used to extract the data from a IsRootEnabledResult.
+func (r IsRootEnabledResult) Extract() (bool, error) {
+	return r.Body.(map[string]interface{})["rootEnabled"] == true, r.Err
+}
diff --git a/openstack/db/v1/instances/urls.go b/openstack/db/v1/instances/urls.go
new file mode 100644
index 0000000..76d1ca5
--- /dev/null
+++ b/openstack/db/v1/instances/urls.go
@@ -0,0 +1,19 @@
+package instances
+
+import "github.com/gophercloud/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("instances")
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("instances", id)
+}
+
+func userRootURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("instances", id, "root")
+}
+
+func actionURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("instances", id, "action")
+}
diff --git a/openstack/db/v1/users/doc.go b/openstack/db/v1/users/doc.go
new file mode 100644
index 0000000..cf07832
--- /dev/null
+++ b/openstack/db/v1/users/doc.go
@@ -0,0 +1,3 @@
+// Package users provides information and interaction with the user API
+// resource in the OpenStack Database service.
+package users
diff --git a/openstack/db/v1/users/fixtures.go b/openstack/db/v1/users/fixtures.go
new file mode 100644
index 0000000..3b27005
--- /dev/null
+++ b/openstack/db/v1/users/fixtures.go
@@ -0,0 +1,37 @@
+package users
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper/fixture"
+)
+
+const user1 = `
+{"databases": [{"name": "databaseA"}],"name": "dbuser3"%s}
+`
+
+const user2 = `
+{"databases": [{"name": "databaseB"},{"name": "databaseC"}],"name": "dbuser4"%s}
+`
+
+var (
+	instanceID = "{instanceID}"
+	_rootURL   = "/instances/" + instanceID + "/users"
+	pUser1     = fmt.Sprintf(user1, `,"password":"secretsecret"`)
+	pUser2     = fmt.Sprintf(user2, `,"password":"secretsecret"`)
+	createReq  = fmt.Sprintf(`{"users":[%s, %s]}`, pUser1, pUser2)
+	listResp   = fmt.Sprintf(`{"users":[%s, %s]}`, fmt.Sprintf(user1, ""), fmt.Sprintf(user2, ""))
+)
+
+func HandleCreate(t *testing.T) {
+	fixture.SetupHandler(t, _rootURL, "POST", createReq, "", 202)
+}
+
+func HandleList(t *testing.T) {
+	fixture.SetupHandler(t, _rootURL, "GET", "", listResp, 200)
+}
+
+func HandleDelete(t *testing.T) {
+	fixture.SetupHandler(t, _rootURL+"/{userName}", "DELETE", "", "", 202)
+}
diff --git a/openstack/db/v1/users/requests.go b/openstack/db/v1/users/requests.go
new file mode 100644
index 0000000..d342de3
--- /dev/null
+++ b/openstack/db/v1/users/requests.go
@@ -0,0 +1,91 @@
+package users
+
+import (
+	"github.com/gophercloud/gophercloud"
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the top-level interface for creating JSON maps.
+type CreateOptsBuilder interface {
+	ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the struct responsible for configuring a new user; often in the
+// context of an instance.
+type CreateOpts struct {
+	// Specifies a name for the user. Valid names can be composed
+	// of the following characters: letters (either case); numbers; these
+	// characters '@', '?', '#', ' ' but NEVER beginning a name string; '_' is
+	// permitted anywhere. Prohibited characters that are forbidden include:
+	// single quotes, double quotes, back quotes, semicolons, commas, backslashes,
+	// and forward slashes. Spaces at the front or end of a user name are also
+	// not permitted.
+	Name string `json:"name" required:"true"`
+	// Specifies a password for the user.
+	Password string `json:"password" required:"true"`
+	// An array of databases that this user will connect to. The
+	// "name" field is the only requirement for each option.
+	Databases db.BatchCreateOpts `json:"databases,omitempty"`
+	// Specifies the host from which a user is allowed to connect to
+	// the database. Possible values are a string containing an IPv4 address or
+	// "%" to allow connecting from any host. Optional; the default is "%".
+	Host string `json:"host,omitempty"`
+}
+
+// ToMap is a convenience function for creating sub-maps for individual users.
+func (opts CreateOpts) ToMap() (map[string]interface{}, error) {
+	if opts.Name == "root" {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "users.CreateOpts.Name"
+		err.Value = "root"
+		err.Info = "root is a reserved user name and cannot be used"
+		return nil, err
+	}
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// BatchCreateOpts allows multiple users to be created at once.
+type BatchCreateOpts []CreateOpts
+
+// ToUserCreateMap will generate a JSON map.
+func (opts BatchCreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+	users := make([]map[string]interface{}, len(opts))
+	for i, opt := range opts {
+		user, err := opt.ToMap()
+		if err != nil {
+			return nil, err
+		}
+		users[i] = user
+	}
+	return map[string]interface{}{"users": users}, nil
+}
+
+// Create asynchronously provisions a new user for the specified database
+// instance based on the configuration defined in CreateOpts. If databases are
+// assigned for a particular user, the user will be granted all privileges
+// for those specified databases. "root" is a reserved name and cannot be used.
+func Create(client *gophercloud.ServiceClient, instanceID string, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToUserCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(baseURL(client, instanceID), &b, nil, nil)
+	return
+}
+
+// List will list all the users associated with a specified database instance,
+// along with their associated databases. This operation will not return any
+// system users or administrators for a database.
+func List(client *gophercloud.ServiceClient, instanceID string) pagination.Pager {
+	return pagination.NewPager(client, baseURL(client, instanceID), func(r pagination.PageResult) pagination.Page {
+		return UserPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Delete will permanently delete a user from a specified database instance.
+func Delete(client *gophercloud.ServiceClient, instanceID, userName string) (r DeleteResult) {
+	_, r.Err = client.Delete(userURL(client, instanceID, userName), nil)
+	return
+}
diff --git a/openstack/db/v1/users/requests_test.go b/openstack/db/v1/users/requests_test.go
new file mode 100644
index 0000000..1eefb33
--- /dev/null
+++ b/openstack/db/v1/users/requests_test.go
@@ -0,0 +1,84 @@
+package users
+
+import (
+	"testing"
+
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreate(t)
+
+	opts := BatchCreateOpts{
+		CreateOpts{
+			Databases: db.BatchCreateOpts{
+				db.CreateOpts{Name: "databaseA"},
+			},
+			Name:     "dbuser3",
+			Password: "secretsecret",
+		},
+		CreateOpts{
+			Databases: db.BatchCreateOpts{
+				db.CreateOpts{Name: "databaseB"},
+				db.CreateOpts{Name: "databaseC"},
+			},
+			Name:     "dbuser4",
+			Password: "secretsecret",
+		},
+	}
+
+	res := Create(fake.ServiceClient(), instanceID, opts)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestUserList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleList(t)
+
+	expectedUsers := []User{
+		User{
+			Databases: []db.Database{
+				db.Database{Name: "databaseA"},
+			},
+			Name: "dbuser3",
+		},
+		User{
+			Databases: []db.Database{
+				db.Database{Name: "databaseB"},
+				db.Database{Name: "databaseC"},
+			},
+			Name: "dbuser4",
+		},
+	}
+
+	pages := 0
+	err := List(fake.ServiceClient(), instanceID).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		actual, err := ExtractUsers(page)
+		if err != nil {
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, expectedUsers, actual)
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, pages)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDelete(t)
+
+	res := Delete(fake.ServiceClient(), instanceID, "{userName}")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/db/v1/users/results.go b/openstack/db/v1/users/results.go
new file mode 100644
index 0000000..d12a681
--- /dev/null
+++ b/openstack/db/v1/users/results.go
@@ -0,0 +1,62 @@
+package users
+
+import (
+	"github.com/gophercloud/gophercloud"
+	db "github.com/gophercloud/gophercloud/openstack/db/v1/databases"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// User represents a database user
+type User struct {
+	// The user name
+	Name string
+
+	// The user password
+	Password string
+
+	// The databases associated with this user
+	Databases []db.Database
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// UserPage represents a single page of a paginated user collection.
+type UserPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty checks to see whether the collection is empty.
+func (page UserPage) IsEmpty() (bool, error) {
+	users, err := ExtractUsers(page)
+	return len(users) == 0, err
+}
+
+// NextPageURL will retrieve the next page URL.
+func (page UserPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"users_links"`
+	}
+	err := page.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractUsers will convert a generic pagination struct into a more
+// relevant slice of User structs.
+func ExtractUsers(r pagination.Page) ([]User, error) {
+	var s struct {
+		Users []User `json:"users"`
+	}
+	err := (r.(UserPage)).ExtractInto(&s)
+	return s.Users, err
+}
diff --git a/openstack/db/v1/users/urls.go b/openstack/db/v1/users/urls.go
new file mode 100644
index 0000000..8c36a39
--- /dev/null
+++ b/openstack/db/v1/users/urls.go
@@ -0,0 +1,11 @@
+package users
+
+import "github.com/gophercloud/gophercloud"
+
+func baseURL(c *gophercloud.ServiceClient, instanceID string) string {
+	return c.ServiceURL("instances", instanceID, "users")
+}
+
+func userURL(c *gophercloud.ServiceClient, instanceID, userName string) string {
+	return c.ServiceURL("instances", instanceID, "users", userName)
+}
diff --git a/openstack/endpoint_location.go b/openstack/endpoint_location.go
new file mode 100644
index 0000000..ea37f5b
--- /dev/null
+++ b/openstack/endpoint_location.go
@@ -0,0 +1,99 @@
+package openstack
+
+import (
+	"github.com/gophercloud/gophercloud"
+	tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+	tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+)
+
+// V2EndpointURL discovers the endpoint URL for a specific service from a ServiceCatalog acquired
+// during the v2 identity service. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V2EndpointURL(catalog *tokens2.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
+	// Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
+	var endpoints = make([]tokens2.Endpoint, 0, 1)
+	for _, entry := range catalog.Entries {
+		if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+			for _, endpoint := range entry.Endpoints {
+				if opts.Region == "" || endpoint.Region == opts.Region {
+					endpoints = append(endpoints, endpoint)
+				}
+			}
+		}
+	}
+
+	// Report an error if the options were ambiguous.
+	if len(endpoints) > 1 {
+		err := &ErrMultipleMatchingEndpointsV2{}
+		err.Endpoints = endpoints
+		return "", err
+	}
+
+	// Extract the appropriate URL from the matching Endpoint.
+	for _, endpoint := range endpoints {
+		switch opts.Availability {
+		case gophercloud.AvailabilityPublic:
+			return gophercloud.NormalizeURL(endpoint.PublicURL), nil
+		case gophercloud.AvailabilityInternal:
+			return gophercloud.NormalizeURL(endpoint.InternalURL), nil
+		case gophercloud.AvailabilityAdmin:
+			return gophercloud.NormalizeURL(endpoint.AdminURL), nil
+		default:
+			err := &ErrInvalidAvailabilityProvided{}
+			err.Argument = "Availability"
+			err.Value = opts.Availability
+			return "", err
+		}
+	}
+
+	// Report an error if there were no matching endpoints.
+	err := &gophercloud.ErrEndpointNotFound{}
+	return "", err
+}
+
+// V3EndpointURL discovers the endpoint URL for a specific service from a Catalog acquired
+// during the v3 identity service. The specified EndpointOpts are used to identify a unique,
+// unambiguous endpoint to return. It's an error both when multiple endpoints match the provided
+// criteria and when none do. The minimum that can be specified is a Type, but you will also often
+// need to specify a Name and/or a Region depending on what's available on your OpenStack
+// deployment.
+func V3EndpointURL(catalog *tokens3.ServiceCatalog, opts gophercloud.EndpointOpts) (string, error) {
+	// Extract Endpoints from the catalog entries that match the requested Type, Interface,
+	// Name if provided, and Region if provided.
+	var endpoints = make([]tokens3.Endpoint, 0, 1)
+	for _, entry := range catalog.Entries {
+		if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+			for _, endpoint := range entry.Endpoints {
+				if opts.Availability != gophercloud.AvailabilityAdmin &&
+					opts.Availability != gophercloud.AvailabilityPublic &&
+					opts.Availability != gophercloud.AvailabilityInternal {
+					err := &ErrInvalidAvailabilityProvided{}
+					err.Argument = "Availability"
+					err.Value = opts.Availability
+					return "", err
+				}
+				if (opts.Availability == gophercloud.Availability(endpoint.Interface)) &&
+					(opts.Region == "" || endpoint.Region == opts.Region) {
+					endpoints = append(endpoints, endpoint)
+				}
+			}
+		}
+	}
+
+	// Report an error if the options were ambiguous.
+	if len(endpoints) > 1 {
+		return "", ErrMultipleMatchingEndpointsV3{Endpoints: endpoints}
+	}
+
+	// Extract the URL from the matching Endpoint.
+	for _, endpoint := range endpoints {
+		return gophercloud.NormalizeURL(endpoint.URL), nil
+	}
+
+	// Report an error if there were no matching endpoints.
+	err := &gophercloud.ErrEndpointNotFound{}
+	return "", err
+}
diff --git a/openstack/endpoint_location_test.go b/openstack/endpoint_location_test.go
new file mode 100644
index 0000000..b538e84
--- /dev/null
+++ b/openstack/endpoint_location_test.go
@@ -0,0 +1,230 @@
+package openstack
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+	tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+// Service catalog fixtures take too much vertical space!
+var catalog2 = tokens2.ServiceCatalog{
+	Entries: []tokens2.CatalogEntry{
+		tokens2.CatalogEntry{
+			Type: "same",
+			Name: "same",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:      "same",
+					PublicURL:   "https://public.correct.com/",
+					InternalURL: "https://internal.correct.com/",
+					AdminURL:    "https://admin.correct.com/",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badregion.com/",
+				},
+			},
+		},
+		tokens2.CatalogEntry{
+			Type: "same",
+			Name: "different",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:    "same",
+					PublicURL: "https://badname.com/",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badname.com/+badregion",
+				},
+			},
+		},
+		tokens2.CatalogEntry{
+			Type: "different",
+			Name: "different",
+			Endpoints: []tokens2.Endpoint{
+				tokens2.Endpoint{
+					Region:    "same",
+					PublicURL: "https://badtype.com/+badname",
+				},
+				tokens2.Endpoint{
+					Region:    "different",
+					PublicURL: "https://badtype.com/+badregion+badname",
+				},
+			},
+		},
+	},
+}
+
+func TestV2EndpointExact(t *testing.T) {
+	expectedURLs := map[gophercloud.Availability]string{
+		gophercloud.AvailabilityPublic:   "https://public.correct.com/",
+		gophercloud.AvailabilityAdmin:    "https://admin.correct.com/",
+		gophercloud.AvailabilityInternal: "https://internal.correct.com/",
+	}
+
+	for availability, expected := range expectedURLs {
+		actual, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+			Type:         "same",
+			Name:         "same",
+			Region:       "same",
+			Availability: availability,
+		})
+		th.AssertNoErr(t, err)
+		th.CheckEquals(t, expected, actual)
+	}
+}
+
+func TestV2EndpointNone(t *testing.T) {
+	_, actual := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "nope",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	expected := &gophercloud.ErrEndpointNotFound{}
+	th.CheckEquals(t, expected.Error(), actual.Error())
+}
+
+func TestV2EndpointMultiple(t *testing.T) {
+	_, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "same",
+		Region:       "same",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	if !strings.HasPrefix(err.Error(), "Discovered 2 matching endpoints:") {
+		t.Errorf("Received unexpected error: %v", err)
+	}
+}
+
+func TestV2EndpointBadAvailability(t *testing.T) {
+	_, err := V2EndpointURL(&catalog2, gophercloud.EndpointOpts{
+		Type:         "same",
+		Name:         "same",
+		Region:       "same",
+		Availability: "wat",
+	})
+	th.CheckEquals(t, "Unexpected availability in endpoint query: wat", err.Error())
+}
+
+var catalog3 = tokens3.ServiceCatalog{
+	Entries: []tokens3.CatalogEntry{
+		tokens3.CatalogEntry{
+			Type: "same",
+			Name: "same",
+			Endpoints: []tokens3.Endpoint{
+				tokens3.Endpoint{
+					ID:        "1",
+					Region:    "same",
+					Interface: "public",
+					URL:       "https://public.correct.com/",
+				},
+				tokens3.Endpoint{
+					ID:        "2",
+					Region:    "same",
+					Interface: "admin",
+					URL:       "https://admin.correct.com/",
+				},
+				tokens3.Endpoint{
+					ID:        "3",
+					Region:    "same",
+					Interface: "internal",
+					URL:       "https://internal.correct.com/",
+				},
+				tokens3.Endpoint{
+					ID:        "4",
+					Region:    "different",
+					Interface: "public",
+					URL:       "https://badregion.com/",
+				},
+			},
+		},
+		tokens3.CatalogEntry{
+			Type: "same",
+			Name: "different",
+			Endpoints: []tokens3.Endpoint{
+				tokens3.Endpoint{
+					ID:        "5",
+					Region:    "same",
+					Interface: "public",
+					URL:       "https://badname.com/",
+				},
+				tokens3.Endpoint{
+					ID:        "6",
+					Region:    "different",
+					Interface: "public",
+					URL:       "https://badname.com/+badregion",
+				},
+			},
+		},
+		tokens3.CatalogEntry{
+			Type: "different",
+			Name: "different",
+			Endpoints: []tokens3.Endpoint{
+				tokens3.Endpoint{
+					ID:        "7",
+					Region:    "same",
+					Interface: "public",
+					URL:       "https://badtype.com/+badname",
+				},
+				tokens3.Endpoint{
+					ID:        "8",
+					Region:    "different",
+					Interface: "public",
+					URL:       "https://badtype.com/+badregion+badname",
+				},
+			},
+		},
+	},
+}
+
+func TestV3EndpointExact(t *testing.T) {
+	expectedURLs := map[gophercloud.Availability]string{
+		gophercloud.AvailabilityPublic:   "https://public.correct.com/",
+		gophercloud.AvailabilityAdmin:    "https://admin.correct.com/",
+		gophercloud.AvailabilityInternal: "https://internal.correct.com/",
+	}
+
+	for availability, expected := range expectedURLs {
+		actual, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+			Type:         "same",
+			Name:         "same",
+			Region:       "same",
+			Availability: availability,
+		})
+		th.AssertNoErr(t, err)
+		th.CheckEquals(t, expected, actual)
+	}
+}
+
+func TestV3EndpointNone(t *testing.T) {
+	_, actual := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+		Type:         "nope",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	expected := &gophercloud.ErrEndpointNotFound{}
+	th.CheckEquals(t, expected.Error(), actual.Error())
+}
+
+func TestV3EndpointMultiple(t *testing.T) {
+	_, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+		Type:         "same",
+		Region:       "same",
+		Availability: gophercloud.AvailabilityPublic,
+	})
+	if !strings.HasPrefix(err.Error(), "Discovered 2 matching endpoints:") {
+		t.Errorf("Received unexpected error: %v", err)
+	}
+}
+
+func TestV3EndpointBadAvailability(t *testing.T) {
+	_, err := V3EndpointURL(&catalog3, gophercloud.EndpointOpts{
+		Type:         "same",
+		Name:         "same",
+		Region:       "same",
+		Availability: "wat",
+	})
+	th.CheckEquals(t, "Unexpected availability in endpoint query: wat", err.Error())
+}
diff --git a/openstack/errors.go b/openstack/errors.go
new file mode 100644
index 0000000..df410b1
--- /dev/null
+++ b/openstack/errors.go
@@ -0,0 +1,71 @@
+package openstack
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+	tokens2 "github.com/gophercloud/gophercloud/openstack/identity/v2/tokens"
+	tokens3 "github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
+)
+
+// ErrEndpointNotFound is the error when no suitable endpoint can be found
+// in the user's catalog
+type ErrEndpointNotFound struct{ gophercloud.BaseError }
+
+func (e ErrEndpointNotFound) Error() string {
+	return "No suitable endpoint could be found in the service catalog."
+}
+
+// ErrInvalidAvailabilityProvided is the error when an invalid endpoint
+// availability is provided
+type ErrInvalidAvailabilityProvided struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrInvalidAvailabilityProvided) Error() string {
+	return fmt.Sprintf("Unexpected availability in endpoint query: %s", e.Value)
+}
+
+// ErrMultipleMatchingEndpointsV2 is the error when more than one endpoint
+// for the given options is found in the v2 catalog
+type ErrMultipleMatchingEndpointsV2 struct {
+	gophercloud.BaseError
+	Endpoints []tokens2.Endpoint
+}
+
+func (e ErrMultipleMatchingEndpointsV2) Error() string {
+	return fmt.Sprintf("Discovered %d matching endpoints: %#v", len(e.Endpoints), e.Endpoints)
+}
+
+// ErrMultipleMatchingEndpointsV3 is the error when more than one endpoint
+// for the given options is found in the v3 catalog
+type ErrMultipleMatchingEndpointsV3 struct {
+	gophercloud.BaseError
+	Endpoints []tokens3.Endpoint
+}
+
+func (e ErrMultipleMatchingEndpointsV3) Error() string {
+	return fmt.Sprintf("Discovered %d matching endpoints: %#v", len(e.Endpoints), e.Endpoints)
+}
+
+// ErrNoAuthURL is the error when the OS_AUTH_URL environment variable is not
+// found
+type ErrNoAuthURL struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoAuthURL) Error() string {
+	return "Environment variable OS_AUTH_URL needs to be set."
+}
+
+// ErrNoUsername is the error when the OS_USERNAME environment variable is not
+// found
+type ErrNoUsername struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoUsername) Error() string {
+	return "Environment variable OS_USERNAME needs to be set."
+}
+
+// ErrNoPassword is the error when the OS_PASSWORD environment variable is not
+// found
+type ErrNoPassword struct{ gophercloud.ErrInvalidInput }
+
+func (e ErrNoPassword) Error() string {
+	return "Environment variable OS_PASSWORD needs to be set."
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/docs.go b/openstack/identity/v2/extensions/admin/roles/docs.go
new file mode 100644
index 0000000..8954178
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/docs.go
@@ -0,0 +1,16 @@
+// Package roles provides functionality to interact with and control roles on
+// the API.
+//
+// A role represents a personality that a user can assume when performing a
+// specific set of operations. If a role includes a set of rights and
+// privileges, a user assuming that role inherits those rights and privileges.
+//
+// When a token is generated, the list of roles that user can assume is returned
+// back to them. Services that are being called by that user determine how they
+// interpret the set of roles a user has and to which operations or resources
+// each role grants access.
+//
+// It is up to individual services such as Compute or Image to assign meaning
+// to these roles. As far as the Identity service is concerned, a role is an
+// arbitrary name assigned by the user.
+package roles
diff --git a/openstack/identity/v2/extensions/admin/roles/fixtures.go b/openstack/identity/v2/extensions/admin/roles/fixtures.go
new file mode 100644
index 0000000..519dfae
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/fixtures.go
@@ -0,0 +1,48 @@
+package roles
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListRoleResponse(t *testing.T) {
+	th.Mux.HandleFunc("/OS-KSADM/roles", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "roles": [
+        {
+            "id": "123",
+            "name": "compute:admin",
+            "description": "Nova Administrator"
+        }
+    ]
+}
+  `)
+	})
+}
+
+func MockAddUserRoleResponse(t *testing.T) {
+	th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusCreated)
+	})
+}
+
+func MockDeleteUserRoleResponse(t *testing.T) {
+	th.Mux.HandleFunc("/tenants/{tenant_id}/users/{user_id}/roles/OS-KSADM/{role_id}", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests.go b/openstack/identity/v2/extensions/admin/roles/requests.go
new file mode 100644
index 0000000..4d27972
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests.go
@@ -0,0 +1,30 @@
+package roles
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List is the operation responsible for listing all available global roles
+// that a user can adopt.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+		return RolePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// AddUser is the operation responsible for assigning a particular role to
+// a user. This is confined to the scope of the user's tenant - so the tenant
+// ID is a required argument.
+func AddUser(client *gophercloud.ServiceClient, tenantID, userID, roleID string) (r UserRoleResult) {
+	_, r.Err = client.Put(userRoleURL(client, tenantID, userID, roleID), nil, nil, nil)
+	return
+}
+
+// DeleteUser is the operation responsible for deleting a particular role
+// from a user. This is confined to the scope of the user's tenant - so the
+// tenant ID is a required argument.
+func DeleteUser(client *gophercloud.ServiceClient, tenantID, userID, roleID string) (r UserRoleResult) {
+	_, r.Err = client.Delete(userRoleURL(client, tenantID, userID, roleID), nil)
+	return
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/requests_test.go b/openstack/identity/v2/extensions/admin/roles/requests_test.go
new file mode 100644
index 0000000..cf3402d
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/requests_test.go
@@ -0,0 +1,64 @@
+package roles
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestRole(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockListRoleResponse(t)
+
+	count := 0
+
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractRoles(page)
+		if err != nil {
+			t.Errorf("Failed to extract users: %v", err)
+			return false, err
+		}
+
+		expected := []Role{
+			Role{
+				ID:          "123",
+				Name:        "compute:admin",
+				Description: "Nova Administrator",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestAddUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockAddUserRoleResponse(t)
+
+	err := AddUser(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+	th.AssertNoErr(t, err)
+}
+
+func TestDeleteUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockDeleteUserRoleResponse(t)
+
+	err := DeleteUser(client.ServiceClient(), "{tenant_id}", "{user_id}", "{role_id}").ExtractErr()
+
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/results.go b/openstack/identity/v2/extensions/admin/roles/results.go
new file mode 100644
index 0000000..28de6bb
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/results.go
@@ -0,0 +1,47 @@
+package roles
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Role represents an API role resource.
+type Role struct {
+	// The unique ID for the role.
+	ID string
+
+	// The human-readable name of the role.
+	Name string
+
+	// The description of the role.
+	Description string
+
+	// The associated service for this role.
+	ServiceID string
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (r RolePage) IsEmpty() (bool, error) {
+	users, err := ExtractRoles(r)
+	return len(users) == 0, err
+}
+
+// ExtractRoles returns a slice of roles contained in a single page of results.
+func ExtractRoles(r pagination.Page) ([]Role, error) {
+	var s struct {
+		Roles []Role `json:"roles"`
+	}
+	err := (r.(RolePage)).ExtractInto(&s)
+	return s.Roles, err
+}
+
+// UserRoleResult represents the result of either an AddUserRole or
+// a DeleteUserRole operation.
+type UserRoleResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/identity/v2/extensions/admin/roles/urls.go b/openstack/identity/v2/extensions/admin/roles/urls.go
new file mode 100644
index 0000000..e4661e8
--- /dev/null
+++ b/openstack/identity/v2/extensions/admin/roles/urls.go
@@ -0,0 +1,21 @@
+package roles
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	ExtPath  = "OS-KSADM"
+	RolePath = "roles"
+	UserPath = "users"
+)
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(ExtPath, RolePath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(ExtPath, RolePath)
+}
+
+func userRoleURL(c *gophercloud.ServiceClient, tenantID, userID, roleID string) string {
+	return c.ServiceURL("tenants", tenantID, UserPath, userID, RolePath, ExtPath, roleID)
+}
diff --git a/openstack/identity/v2/extensions/delegate.go b/openstack/identity/v2/extensions/delegate.go
new file mode 100644
index 0000000..cf6cc81
--- /dev/null
+++ b/openstack/identity/v2/extensions/delegate.go
@@ -0,0 +1,46 @@
+package extensions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ExtensionPage is a single page of Extension results.
+type ExtensionPage struct {
+	common.ExtensionPage
+}
+
+// IsEmpty returns true if the current page contains at least one Extension.
+func (page ExtensionPage) IsEmpty() (bool, error) {
+	is, err := ExtractExtensions(page)
+	return len(is) == 0, err
+}
+
+// ExtractExtensions accepts a Page struct, specifically an ExtensionPage struct, and extracts the
+// elements into a slice of Extension structs.
+func ExtractExtensions(page pagination.Page) ([]common.Extension, error) {
+	// Identity v2 adds an intermediate "values" object.
+	var s struct {
+		Extensions struct {
+			Values []common.Extension `json:"values"`
+		} `json:"extensions"`
+	}
+	err := page.(ExtensionPage).ExtractInto(&s)
+	return s.Extensions.Values, err
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) common.GetResult {
+	return common.Get(c, alias)
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return common.List(c).WithPageCreator(func(r pagination.PageResult) pagination.Page {
+		return ExtensionPage{
+			ExtensionPage: common.ExtensionPage{SinglePageBase: pagination.SinglePageBase(r)},
+		}
+	})
+}
diff --git a/openstack/identity/v2/extensions/delegate_test.go b/openstack/identity/v2/extensions/delegate_test.go
new file mode 100644
index 0000000..b425130
--- /dev/null
+++ b/openstack/identity/v2/extensions/delegate_test.go
@@ -0,0 +1,38 @@
+package extensions
+
+import (
+	"testing"
+
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListExtensionsSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		th.AssertNoErr(t, err)
+		th.CheckDeepEquals(t, common.ExpectedExtensions, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	common.HandleGetExtensionSuccessfully(t)
+
+	actual, err := Get(client.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, common.SingleExtension, actual)
+}
diff --git a/openstack/identity/v2/extensions/doc.go b/openstack/identity/v2/extensions/doc.go
new file mode 100644
index 0000000..791e4e3
--- /dev/null
+++ b/openstack/identity/v2/extensions/doc.go
@@ -0,0 +1,3 @@
+// Package extensions provides information and interaction with the
+// different extensions available for the OpenStack Identity service.
+package extensions
diff --git a/openstack/identity/v2/extensions/fixtures.go b/openstack/identity/v2/extensions/fixtures.go
new file mode 100644
index 0000000..3fd5ad9
--- /dev/null
+++ b/openstack/identity/v2/extensions/fixtures.go
@@ -0,0 +1,60 @@
+// +build fixtures
+
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single Extension result. It differs from the delegated implementation
+// by the introduction of an intermediate "values" member.
+const ListOutput = `
+{
+	"extensions": {
+		"values": [
+			{
+				"updated": "2013-01-20T00:00:00-00:00",
+				"name": "Neutron Service Type Management",
+				"links": [],
+				"namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+				"alias": "service-type",
+				"description": "API for retrieving service providers for Neutron advanced services"
+			}
+		]
+	}
+}
+`
+
+// HandleListExtensionsSuccessfully creates an HTTP handler that returns ListOutput for a List
+// call.
+func HandleListExtensionsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, `
+{
+  "extensions": {
+    "values": [
+      {
+        "updated": "2013-01-20T00:00:00-00:00",
+        "name": "Neutron Service Type Management",
+        "links": [],
+        "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+        "alias": "service-type",
+        "description": "API for retrieving service providers for Neutron advanced services"
+      }
+    ]
+  }
+}
+    `)
+	})
+
+}
diff --git a/openstack/identity/v2/tenants/doc.go b/openstack/identity/v2/tenants/doc.go
new file mode 100644
index 0000000..0c2d49d
--- /dev/null
+++ b/openstack/identity/v2/tenants/doc.go
@@ -0,0 +1,7 @@
+// Package tenants provides information and interaction with the
+// tenants API resource for the OpenStack Identity service.
+//
+// See http://developer.openstack.org/api-ref-identity-v2.html#identity-auth-v2
+// and http://developer.openstack.org/api-ref-identity-v2.html#admin-tenants
+// for more information.
+package tenants
diff --git a/openstack/identity/v2/tenants/fixtures.go b/openstack/identity/v2/tenants/fixtures.go
new file mode 100644
index 0000000..3ccc791
--- /dev/null
+++ b/openstack/identity/v2/tenants/fixtures.go
@@ -0,0 +1,65 @@
+// +build fixtures
+
+package tenants
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ListOutput provides a single page of Tenant results.
+const ListOutput = `
+{
+	"tenants": [
+		{
+			"id": "1234",
+			"name": "Red Team",
+			"description": "The team that is red",
+			"enabled": true
+		},
+		{
+			"id": "9876",
+			"name": "Blue Team",
+			"description": "The team that is blue",
+			"enabled": false
+		}
+	]
+}
+`
+
+// RedTeam is a Tenant fixture.
+var RedTeam = Tenant{
+	ID:          "1234",
+	Name:        "Red Team",
+	Description: "The team that is red",
+	Enabled:     true,
+}
+
+// BlueTeam is a Tenant fixture.
+var BlueTeam = Tenant{
+	ID:          "9876",
+	Name:        "Blue Team",
+	Description: "The team that is blue",
+	Enabled:     false,
+}
+
+// ExpectedTenantSlice is the slice of tenants expected to be returned from ListOutput.
+var ExpectedTenantSlice = []Tenant{RedTeam, BlueTeam}
+
+// HandleListTenantsSuccessfully creates an HTTP handler at `/tenants` on the test handler mux that
+// responds with a list of two tenants.
+func HandleListTenantsSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/tenants", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, ListOutput)
+	})
+}
diff --git a/openstack/identity/v2/tenants/requests.go b/openstack/identity/v2/tenants/requests.go
new file mode 100644
index 0000000..b9d7de6
--- /dev/null
+++ b/openstack/identity/v2/tenants/requests.go
@@ -0,0 +1,29 @@
+package tenants
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts filters the Tenants that are returned by the List call.
+type ListOpts struct {
+	// Marker is the ID of the last Tenant on the previous page.
+	Marker string `q:"marker"`
+	// Limit specifies the page size.
+	Limit int `q:"limit"`
+}
+
+// List enumerates the Tenants to which the current token has access.
+func List(client *gophercloud.ServiceClient, opts *ListOpts) pagination.Pager {
+	url := listURL(client)
+	if opts != nil {
+		q, err := gophercloud.BuildQueryString(opts)
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += q.String()
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return TenantPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
diff --git a/openstack/identity/v2/tenants/requests_test.go b/openstack/identity/v2/tenants/requests_test.go
new file mode 100644
index 0000000..d7a4db0
--- /dev/null
+++ b/openstack/identity/v2/tenants/requests_test.go
@@ -0,0 +1,29 @@
+package tenants
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListTenants(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListTenantsSuccessfully(t)
+
+	count := 0
+	err := List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+
+		actual, err := ExtractTenants(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ExpectedTenantSlice, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
diff --git a/openstack/identity/v2/tenants/results.go b/openstack/identity/v2/tenants/results.go
new file mode 100644
index 0000000..3ce1e67
--- /dev/null
+++ b/openstack/identity/v2/tenants/results.go
@@ -0,0 +1,53 @@
+package tenants
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Tenant is a grouping of users in the identity service.
+type Tenant struct {
+	// ID is a unique identifier for this tenant.
+	ID string `json:"id"`
+
+	// Name is a friendlier user-facing name for this tenant.
+	Name string `json:"name"`
+
+	// Description is a human-readable explanation of this Tenant's purpose.
+	Description string `json:"description"`
+
+	// Enabled indicates whether or not a tenant is active.
+	Enabled bool `json:"enabled"`
+}
+
+// TenantPage is a single page of Tenant results.
+type TenantPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (r TenantPage) IsEmpty() (bool, error) {
+	tenants, err := ExtractTenants(r)
+	return len(tenants) == 0, err
+}
+
+// NextPageURL extracts the "next" link from the tenants_links section of the result.
+func (r TenantPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"tenants_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// ExtractTenants returns a slice of Tenants contained in a single page of results.
+func ExtractTenants(r pagination.Page) ([]Tenant, error) {
+	var s struct {
+		Tenants []Tenant `json:"tenants"`
+	}
+	err := (r.(TenantPage)).ExtractInto(&s)
+	return s.Tenants, err
+}
diff --git a/openstack/identity/v2/tenants/urls.go b/openstack/identity/v2/tenants/urls.go
new file mode 100644
index 0000000..101599b
--- /dev/null
+++ b/openstack/identity/v2/tenants/urls.go
@@ -0,0 +1,7 @@
+package tenants
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("tenants")
+}
diff --git a/openstack/identity/v2/tokens/doc.go b/openstack/identity/v2/tokens/doc.go
new file mode 100644
index 0000000..31cacc5
--- /dev/null
+++ b/openstack/identity/v2/tokens/doc.go
@@ -0,0 +1,5 @@
+// Package tokens provides information and interaction with the token API
+// resource for the OpenStack Identity service.
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v2.html#identity-auth-v2
+package tokens
diff --git a/openstack/identity/v2/tokens/fixtures.go b/openstack/identity/v2/tokens/fixtures.go
new file mode 100644
index 0000000..85a9571
--- /dev/null
+++ b/openstack/identity/v2/tokens/fixtures.go
@@ -0,0 +1,195 @@
+// +build fixtures
+
+package tokens
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	thclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ExpectedToken is the token that should be parsed from TokenCreationResponse.
+var ExpectedToken = &Token{
+	ID:        "aaaabbbbccccdddd",
+	ExpiresAt: time.Date(2014, time.January, 31, 15, 30, 58, 0, time.UTC),
+	Tenant: tenants.Tenant{
+		ID:          "fc394f2ab2df4114bde39905f800dc57",
+		Name:        "test",
+		Description: "There are many tenants. This one is yours.",
+		Enabled:     true,
+	},
+}
+
+// ExpectedServiceCatalog is the service catalog that should be parsed from TokenCreationResponse.
+var ExpectedServiceCatalog = &ServiceCatalog{
+	Entries: []CatalogEntry{
+		CatalogEntry{
+			Name: "inscrutablewalrus",
+			Type: "something",
+			Endpoints: []Endpoint{
+				Endpoint{
+					PublicURL: "http://something0:1234/v2/",
+					Region:    "region0",
+				},
+				Endpoint{
+					PublicURL: "http://something1:1234/v2/",
+					Region:    "region1",
+				},
+			},
+		},
+		CatalogEntry{
+			Name: "arbitrarypenguin",
+			Type: "else",
+			Endpoints: []Endpoint{
+				Endpoint{
+					PublicURL: "http://else0:4321/v3/",
+					Region:    "region0",
+				},
+			},
+		},
+	},
+}
+
+// ExpectedUser is the token that should be parsed from TokenGetResponse.
+var ExpectedUser = &User{
+	ID:       "a530fefc3d594c4ba2693a4ecd6be74e",
+	Name:     "apiserver",
+	Roles:    []Role{{"member"}, {"service"}},
+	UserName: "apiserver",
+}
+
+// TokenCreationResponse is a JSON response that contains ExpectedToken and ExpectedServiceCatalog.
+const TokenCreationResponse = `
+{
+	"access": {
+		"token": {
+			"issued_at": "2014-01-30T15:30:58.000000Z",
+			"expires": "2014-01-31T15:30:58Z",
+			"id": "aaaabbbbccccdddd",
+			"tenant": {
+				"description": "There are many tenants. This one is yours.",
+				"enabled": true,
+				"id": "fc394f2ab2df4114bde39905f800dc57",
+				"name": "test"
+			}
+		},
+		"serviceCatalog": [
+			{
+				"endpoints": [
+					{
+						"publicURL": "http://something0:1234/v2/",
+						"region": "region0"
+					},
+					{
+						"publicURL": "http://something1:1234/v2/",
+						"region": "region1"
+					}
+				],
+				"type": "something",
+				"name": "inscrutablewalrus"
+			},
+			{
+				"endpoints": [
+					{
+						"publicURL": "http://else0:4321/v3/",
+						"region": "region0"
+					}
+				],
+				"type": "else",
+				"name": "arbitrarypenguin"
+			}
+		]
+	}
+}
+`
+
+// TokenGetResponse is a JSON response that contains ExpectedToken and ExpectedUser.
+const TokenGetResponse = `
+{
+    "access": {
+		"token": {
+			"issued_at": "2014-01-30T15:30:58.000000Z",
+			"expires": "2014-01-31T15:30:58Z",
+			"id": "aaaabbbbccccdddd",
+			"tenant": {
+				"description": "There are many tenants. This one is yours.",
+				"enabled": true,
+				"id": "fc394f2ab2df4114bde39905f800dc57",
+				"name": "test"
+			}
+		},
+        "serviceCatalog": [], 
+		"user": {
+            "id": "a530fefc3d594c4ba2693a4ecd6be74e", 
+            "name": "apiserver", 
+            "roles": [
+                {
+                    "name": "member"
+                }, 
+                {
+                    "name": "service"
+                }
+            ], 
+            "roles_links": [], 
+            "username": "apiserver"
+        }
+    }
+}`
+
+// HandleTokenPost expects a POST against a /tokens handler, ensures that the request body has been
+// constructed properly given certain auth options, and returns the result.
+func HandleTokenPost(t *testing.T, requestJSON string) {
+	th.Mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		if requestJSON != "" {
+			th.TestJSONRequest(t, r, requestJSON)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, TokenCreationResponse)
+	})
+}
+
+// HandleTokenGet expects a Get against a /tokens handler, ensures that the request body has been
+// constructed properly given certain auth options, and returns the result.
+func HandleTokenGet(t *testing.T, token string) {
+	th.Mux.HandleFunc("/tokens/"+token, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "X-Auth-Token", thclient.TokenID)
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, TokenGetResponse)
+	})
+}
+
+// IsSuccessful ensures that a CreateResult was successful and contains the correct token and
+// service catalog.
+func IsSuccessful(t *testing.T, result CreateResult) {
+	token, err := result.ExtractToken()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedToken, token)
+
+	serviceCatalog, err := result.ExtractServiceCatalog()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedServiceCatalog, serviceCatalog)
+}
+
+// GetIsSuccessful ensures that a GetResult was successful and contains the correct token and
+// User Info.
+func GetIsSuccessful(t *testing.T, result GetResult) {
+	token, err := result.ExtractToken()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedToken, token)
+
+	user, err := result.ExtractUser()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedUser, user)
+}
diff --git a/openstack/identity/v2/tokens/requests.go b/openstack/identity/v2/tokens/requests.go
new file mode 100644
index 0000000..1c4ba7c
--- /dev/null
+++ b/openstack/identity/v2/tokens/requests.go
@@ -0,0 +1,98 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+type PasswordCredentialsV2 struct {
+	Username string `json:"username" required:"true"`
+	Password string `json:"password" required:"true"`
+}
+
+type TokenCredentialsV2 struct {
+	ID string `json:"id,omitempty" required:"true"`
+}
+
+// AuthOptionsV2 wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+// interface.
+type AuthOptionsV2 struct {
+	PasswordCredentials *PasswordCredentialsV2 `json:"passwordCredentials,omitempty" xor:"TokenCredentials"`
+
+	// The TenantID and TenantName fields are optional for the Identity V2 API.
+	// Some providers allow you to specify a TenantName instead of the TenantId.
+	// Some require both. Your provider's authentication policies will determine
+	// how these fields influence authentication.
+	TenantID   string `json:"tenantId,omitempty"`
+	TenantName string `json:"tenantName,omitempty"`
+
+	// TokenCredentials allows users to authenticate (possibly as another user) with an
+	// authentication token ID.
+	TokenCredentials *TokenCredentialsV2 `json:"token,omitempty" xor:"PasswordCredentials"`
+}
+
+// AuthOptionsBuilder describes any argument that may be passed to the Create call.
+type AuthOptionsBuilder interface {
+	// ToTokenCreateMap assembles the Create request body, returning an error if parameters are
+	// missing or inconsistent.
+	ToTokenV2CreateMap() (map[string]interface{}, error)
+}
+
+// AuthOptions are the valid options for Openstack Identity v2 authentication.
+// For field descriptions, see gophercloud.AuthOptions.
+type AuthOptions struct {
+	IdentityEndpoint string `json:"-"`
+	Username         string `json:"username,omitempty"`
+	Password         string `json:"password,omitempty"`
+	TenantID         string `json:"tenantId,omitempty"`
+	TenantName       string `json:"tenantName,omitempty"`
+	AllowReauth      bool   `json:"-"`
+	TokenID          string
+}
+
+// ToTokenV2CreateMap allows AuthOptions to satisfy the AuthOptionsBuilder
+// interface in the v2 tokens package
+func (opts AuthOptions) ToTokenV2CreateMap() (map[string]interface{}, error) {
+	v2Opts := AuthOptionsV2{
+		TenantID:   opts.TenantID,
+		TenantName: opts.TenantName,
+	}
+
+	if opts.Password != "" {
+		v2Opts.PasswordCredentials = &PasswordCredentialsV2{
+			Username: opts.Username,
+			Password: opts.Password,
+		}
+	} else {
+		v2Opts.TokenCredentials = &TokenCredentialsV2{
+			ID: opts.TokenID,
+		}
+	}
+
+	b, err := gophercloud.BuildRequestBody(v2Opts, "auth")
+	if err != nil {
+		return nil, err
+	}
+	return b, nil
+}
+
+// Create authenticates to the identity service and attempts to acquire a Token.
+// If successful, the CreateResult
+// Generally, rather than interact with this call directly, end users should call openstack.AuthenticatedClient(),
+// which abstracts all of the gory details about navigating service catalogs and such.
+func Create(client *gophercloud.ServiceClient, auth AuthOptionsBuilder) (r CreateResult) {
+	b, err := auth.ToTokenV2CreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(CreateURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 203},
+	})
+	return
+}
+
+// Get validates and retrieves information for user's token.
+func Get(client *gophercloud.ServiceClient, token string) (r GetResult) {
+	_, r.Err = client.Get(GetURL(client, token), &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 203},
+	})
+	return
+}
diff --git a/openstack/identity/v2/tokens/requests_test.go b/openstack/identity/v2/tokens/requests_test.go
new file mode 100644
index 0000000..a6d16f3
--- /dev/null
+++ b/openstack/identity/v2/tokens/requests_test.go
@@ -0,0 +1,103 @@
+package tokens
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func tokenPost(t *testing.T, options gophercloud.AuthOptions, requestJSON string) CreateResult {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleTokenPost(t, requestJSON)
+
+	return Create(client.ServiceClient(), options)
+}
+
+func tokenPostErr(t *testing.T, options gophercloud.AuthOptions, expectedErr error) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleTokenPost(t, "")
+
+	actualErr := Create(client.ServiceClient(), options).Err
+	th.CheckDeepEquals(t, expectedErr, actualErr)
+}
+
+func TestCreateWithPassword(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Username: "me",
+		Password: "swordfish",
+	}
+
+	IsSuccessful(t, tokenPost(t, options, `
+    {
+      "auth": {
+        "passwordCredentials": {
+          "username": "me",
+          "password": "swordfish"
+        }
+      }
+    }
+  `))
+}
+
+func TestCreateTokenWithTenantID(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Username: "me",
+		Password: "opensesame",
+		TenantID: "fc394f2ab2df4114bde39905f800dc57",
+	}
+
+	IsSuccessful(t, tokenPost(t, options, `
+    {
+      "auth": {
+        "tenantId": "fc394f2ab2df4114bde39905f800dc57",
+        "passwordCredentials": {
+          "username": "me",
+          "password": "opensesame"
+        }
+      }
+    }
+  `))
+}
+
+func TestCreateTokenWithTenantName(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Username:   "me",
+		Password:   "opensesame",
+		TenantName: "demo",
+	}
+
+	IsSuccessful(t, tokenPost(t, options, `
+    {
+      "auth": {
+        "tenantName": "demo",
+        "passwordCredentials": {
+          "username": "me",
+          "password": "opensesame"
+        }
+      }
+    }
+  `))
+}
+
+func TestRequireUsername(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Password: "thing",
+	}
+
+	tokenPostErr(t, options, gophercloud.ErrMissingInput{Argument: "Username"})
+}
+
+func tokenGet(t *testing.T, tokenId string) GetResult {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleTokenGet(t, tokenId)
+	return Get(client.ServiceClient(), tokenId)
+}
+
+func TestGetWithToken(t *testing.T) {
+	GetIsSuccessful(t, tokenGet(t, "db22caf43c934e6c829087c41ff8d8d6"))
+}
diff --git a/openstack/identity/v2/tokens/results.go b/openstack/identity/v2/tokens/results.go
new file mode 100644
index 0000000..93c0554
--- /dev/null
+++ b/openstack/identity/v2/tokens/results.go
@@ -0,0 +1,149 @@
+package tokens
+
+import (
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/identity/v2/tenants"
+)
+
+// Token provides only the most basic information related to an authentication token.
+type Token struct {
+	// ID provides the primary means of identifying a user to the OpenStack API.
+	// OpenStack defines this field as an opaque value, so do not depend on its content.
+	// It is safe, however, to compare for equality.
+	ID string
+
+	// ExpiresAt provides a timestamp in ISO 8601 format, indicating when the authentication token becomes invalid.
+	// After this point in time, future API requests made using this authentication token will respond with errors.
+	// Either the caller will need to reauthenticate manually, or more preferably, the caller should exploit automatic re-authentication.
+	// See the AuthOptions structure for more details.
+	ExpiresAt time.Time
+
+	// Tenant provides information about the tenant to which this token grants access.
+	Tenant tenants.Tenant
+}
+
+// Role is a role for a user.
+type Role struct {
+	Name string `json:"name"`
+}
+
+// User is an OpenStack user.
+type User struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`
+	UserName string `json:"username"`
+	Roles    []Role `json:"roles"`
+}
+
+// Endpoint represents a single API endpoint offered by a service.
+// It provides the public and internal URLs, if supported, along with a region specifier, again if provided.
+// The significance of the Region field will depend upon your provider.
+//
+// In addition, the interface offered by the service will have version information associated with it
+// through the VersionId, VersionInfo, and VersionList fields, if provided or supported.
+//
+// In all cases, fields which aren't supported by the provider and service combined will assume a zero-value ("").
+type Endpoint struct {
+	TenantID    string `json:"tenantId"`
+	PublicURL   string `json:"publicURL"`
+	InternalURL string `json:"internalURL"`
+	AdminURL    string `json:"adminURL"`
+	Region      string `json:"region"`
+	VersionID   string `json:"versionId"`
+	VersionInfo string `json:"versionInfo"`
+	VersionList string `json:"versionList"`
+}
+
+// CatalogEntry provides a type-safe interface to an Identity API V2 service catalog listing.
+// Each class of service, such as cloud DNS or block storage services, will have a single
+// CatalogEntry representing it.
+//
+// Note: when looking for the desired service, try, whenever possible, to key off the type field.
+// Otherwise, you'll tie the representation of the service to a specific provider.
+type CatalogEntry struct {
+	// Name will contain the provider-specified name for the service.
+	Name string `json:"name"`
+
+	// Type will contain a type string if OpenStack defines a type for the service.
+	// Otherwise, for provider-specific services, the provider may assign their own type strings.
+	Type string `json:"type"`
+
+	// Endpoints will let the caller iterate over all the different endpoints that may exist for
+	// the service.
+	Endpoints []Endpoint `json:"endpoints"`
+}
+
+// ServiceCatalog provides a view into the service catalog from a previous, successful authentication.
+type ServiceCatalog struct {
+	Entries []CatalogEntry
+}
+
+// CreateResult defers the interpretation of a created token.
+// Use ExtractToken() to interpret it as a Token, or ExtractServiceCatalog() to interpret it as a service catalog.
+type CreateResult struct {
+	gophercloud.Result
+}
+
+// GetResult is the deferred response from a Get call, which is the same with a Created token.
+// Use ExtractUser() to interpret it as a User.
+type GetResult struct {
+	CreateResult
+}
+
+// ExtractToken returns the just-created Token from a CreateResult.
+func (r CreateResult) ExtractToken() (*Token, error) {
+	var s struct {
+		Access struct {
+			Token struct {
+				Expires string         `json:"expires"`
+				ID      string         `json:"id"`
+				Tenant  tenants.Tenant `json:"tenant"`
+			} `json:"token"`
+		} `json:"access"`
+	}
+
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return nil, err
+	}
+
+	expiresTs, err := time.Parse(gophercloud.RFC3339Milli, s.Access.Token.Expires)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Token{
+		ID:        s.Access.Token.ID,
+		ExpiresAt: expiresTs,
+		Tenant:    s.Access.Token.Tenant,
+	}, nil
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (r CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+	var s struct {
+		Access struct {
+			Entries []CatalogEntry `json:"serviceCatalog"`
+		} `json:"access"`
+	}
+	err := r.ExtractInto(&s)
+	return &ServiceCatalog{Entries: s.Access.Entries}, err
+}
+
+// createErr quickly packs an error in a CreateResult.
+func createErr(err error) CreateResult {
+	return CreateResult{gophercloud.Result{Err: err}}
+}
+
+// ExtractUser returns the User from a GetResult.
+func (r GetResult) ExtractUser() (*User, error) {
+	var s struct {
+		Access struct {
+			User User `json:"user"`
+		} `json:"access"`
+	}
+	err := r.ExtractInto(&s)
+	return &s.Access.User, err
+}
diff --git a/openstack/identity/v2/tokens/urls.go b/openstack/identity/v2/tokens/urls.go
new file mode 100644
index 0000000..ee0a28f
--- /dev/null
+++ b/openstack/identity/v2/tokens/urls.go
@@ -0,0 +1,13 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+// CreateURL generates the URL used to create new Tokens.
+func CreateURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("tokens")
+}
+
+// GetURL generates the URL used to Validate Tokens.
+func GetURL(client *gophercloud.ServiceClient, token string) string {
+	return client.ServiceURL("tokens", token)
+}
diff --git a/openstack/identity/v2/users/doc.go b/openstack/identity/v2/users/doc.go
new file mode 100644
index 0000000..82abcb9
--- /dev/null
+++ b/openstack/identity/v2/users/doc.go
@@ -0,0 +1 @@
+package users
diff --git a/openstack/identity/v2/users/fixtures.go b/openstack/identity/v2/users/fixtures.go
new file mode 100644
index 0000000..ecd1768
--- /dev/null
+++ b/openstack/identity/v2/users/fixtures.go
@@ -0,0 +1,163 @@
+package users
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func MockListUserResponse(t *testing.T) {
+	th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "users":[
+        {
+            "id": "u1000",
+						"name": "John Smith",
+            "username": "jqsmith",
+            "email": "john.smith@example.org",
+            "enabled": true,
+						"tenant_id": "12345"
+        },
+        {
+            "id": "u1001",
+						"name": "Jane Smith",
+            "username": "jqsmith",
+            "email": "jane.smith@example.org",
+            "enabled": true,
+						"tenant_id": "12345"
+        }
+    ]
+}
+  `)
+	})
+}
+
+func mockCreateUserResponse(t *testing.T) {
+	th.Mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+    "user": {
+		    "name": "new_user",
+		    "tenant_id": "12345",
+				"enabled": false,
+				"email": "new_user@foo.com"
+    }
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "user": {
+        "name": "new_user",
+        "tenant_id": "12345",
+        "enabled": false,
+        "email": "new_user@foo.com",
+        "id": "c39e3de9be2d4c779f1dfd6abacc176d"
+    }
+}
+`)
+	})
+}
+
+func mockGetUserResponse(t *testing.T) {
+	th.Mux.HandleFunc("/users/new_user", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+		"user": {
+				"name": "new_user",
+				"tenant_id": "12345",
+				"enabled": false,
+				"email": "new_user@foo.com",
+				"id": "c39e3de9be2d4c779f1dfd6abacc176d"
+		}
+}
+`)
+	})
+}
+
+func mockUpdateUserResponse(t *testing.T) {
+	th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		th.TestJSONRequest(t, r, `
+{
+    "user": {
+		    "name": "new_name",
+		    "enabled": true,
+		    "email": "new_email@foo.com"
+    }
+}
+`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+		"user": {
+				"name": "new_name",
+				"tenant_id": "12345",
+				"enabled": true,
+				"email": "new_email@foo.com",
+				"id": "c39e3de9be2d4c779f1dfd6abacc176d"
+		}
+}
+`)
+	})
+}
+
+func mockDeleteUserResponse(t *testing.T) {
+	th.Mux.HandleFunc("/users/c39e3de9be2d4c779f1dfd6abacc176d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+func mockListRolesResponse(t *testing.T) {
+	th.Mux.HandleFunc("/tenants/1d8b6120dcc640fda4fc9194ffc80273/users/c39e3de9be2d4c779f1dfd6abacc176d/roles", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "roles": [
+        {
+            "id": "9fe2ff9ee4384b1894a90878d3e92bab",
+            "name": "foo_role"
+        },
+        {
+            "id": "1ea3d56793574b668e85960fbf651e13",
+            "name": "admin"
+        }
+    ]
+}
+	`)
+	})
+}
diff --git a/openstack/identity/v2/users/requests.go b/openstack/identity/v2/users/requests.go
new file mode 100644
index 0000000..ef77d39
--- /dev/null
+++ b/openstack/identity/v2/users/requests.go
@@ -0,0 +1,106 @@
+package users
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// List lists the existing users.
+func List(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, rootURL(client), func(r pagination.PageResult) pagination.Page {
+		return UserPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// CommonOpts are the parameters that are shared between CreateOpts and
+// UpdateOpts
+type CommonOpts struct {
+	// Either a name or username is required. When provided, the value must be
+	// unique or a 409 conflict error will be returned. If you provide a name but
+	// omit a username, the latter will be set to the former; and vice versa.
+	Name     string `json:"name,omitempty"`
+	Username string `json:"username,omitempty"`
+	// The ID of the tenant to which you want to assign this user.
+	TenantID string `json:"tenant_id,omitempty"`
+	// Indicates whether this user is enabled or not.
+	Enabled *bool `json:"enabled,omitempty"`
+	// The email address of this user.
+	Email string `json:"email,omitempty"`
+}
+
+// CreateOpts represents the options needed when creating new users.
+type CreateOpts CommonOpts
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+type CreateOptsBuilder interface {
+	ToUserCreateMap() (map[string]interface{}, error)
+}
+
+// ToUserCreateMap assembles a request body based on the contents of a CreateOpts.
+func (opts CreateOpts) ToUserCreateMap() (map[string]interface{}, error) {
+	if opts.Name == "" && opts.Username == "" {
+		err := gophercloud.ErrMissingInput{}
+		err.Argument = "users.CreateOpts.Name/users.CreateOpts.Username"
+		err.Info = "Either a Name or Username must be provided"
+		return nil, err
+	}
+	return gophercloud.BuildRequestBody(opts, "user")
+}
+
+// Create is the operation responsible for creating new users.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToUserCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(rootURL(client), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Get requests details on a single user, either by ID.
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = client.Get(ResourceURL(client, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional attributes to the Update request.
+type UpdateOptsBuilder interface {
+	ToUserUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts specifies the base attributes that may be updated on an existing server.
+type UpdateOpts CommonOpts
+
+// ToUserUpdateMap formats an UpdateOpts structure into a request body.
+func (opts UpdateOpts) ToUserUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "user")
+}
+
+// Update is the operation responsible for updating exist users by their UUID.
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToUserUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Put(ResourceURL(client, id), &b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete is the operation responsible for permanently deleting an API user.
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = client.Delete(ResourceURL(client, id), nil)
+	return
+}
+
+// ListRoles lists the existing roles that can be assigned to users.
+func ListRoles(client *gophercloud.ServiceClient, tenantID, userID string) pagination.Pager {
+	return pagination.NewPager(client, listRolesURL(client, tenantID, userID), func(r pagination.PageResult) pagination.Page {
+		return RolePage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/identity/v2/users/requests_test.go b/openstack/identity/v2/users/requests_test.go
new file mode 100644
index 0000000..0e6da37
--- /dev/null
+++ b/openstack/identity/v2/users/requests_test.go
@@ -0,0 +1,160 @@
+package users
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	MockListUserResponse(t)
+
+	count := 0
+
+	err := List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractUsers(page)
+		th.AssertNoErr(t, err)
+
+		expected := []User{
+			User{
+				ID:       "u1000",
+				Name:     "John Smith",
+				Username: "jqsmith",
+				Email:    "john.smith@example.org",
+				Enabled:  true,
+				TenantID: "12345",
+			},
+			User{
+				ID:       "u1001",
+				Name:     "Jane Smith",
+				Username: "jqsmith",
+				Email:    "jane.smith@example.org",
+				Enabled:  true,
+				TenantID: "12345",
+			},
+		}
+		th.CheckDeepEquals(t, expected, actual)
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestCreateUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockCreateUserResponse(t)
+
+	opts := CreateOpts{
+		Name:     "new_user",
+		TenantID: "12345",
+		Enabled:  gophercloud.Disabled,
+		Email:    "new_user@foo.com",
+	}
+
+	user, err := Create(client.ServiceClient(), opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	expected := &User{
+		Name:     "new_user",
+		ID:       "c39e3de9be2d4c779f1dfd6abacc176d",
+		Email:    "new_user@foo.com",
+		Enabled:  false,
+		TenantID: "12345",
+	}
+
+	th.AssertDeepEquals(t, expected, user)
+}
+
+func TestGetUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockGetUserResponse(t)
+
+	user, err := Get(client.ServiceClient(), "new_user").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &User{
+		Name:     "new_user",
+		ID:       "c39e3de9be2d4c779f1dfd6abacc176d",
+		Email:    "new_user@foo.com",
+		Enabled:  false,
+		TenantID: "12345",
+	}
+
+	th.AssertDeepEquals(t, expected, user)
+}
+
+func TestUpdateUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockUpdateUserResponse(t)
+
+	id := "c39e3de9be2d4c779f1dfd6abacc176d"
+	opts := UpdateOpts{
+		Name:    "new_name",
+		Enabled: gophercloud.Enabled,
+		Email:   "new_email@foo.com",
+	}
+
+	user, err := Update(client.ServiceClient(), id, opts).Extract()
+
+	th.AssertNoErr(t, err)
+
+	expected := &User{
+		Name:     "new_name",
+		ID:       id,
+		Email:    "new_email@foo.com",
+		Enabled:  true,
+		TenantID: "12345",
+	}
+
+	th.AssertDeepEquals(t, expected, user)
+}
+
+func TestDeleteUser(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockDeleteUserResponse(t)
+
+	res := Delete(client.ServiceClient(), "c39e3de9be2d4c779f1dfd6abacc176d")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestListingUserRoles(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	mockListRolesResponse(t)
+
+	tenantID := "1d8b6120dcc640fda4fc9194ffc80273"
+	userID := "c39e3de9be2d4c779f1dfd6abacc176d"
+
+	err := ListRoles(client.ServiceClient(), tenantID, userID).EachPage(func(page pagination.Page) (bool, error) {
+		actual, err := ExtractRoles(page)
+		th.AssertNoErr(t, err)
+
+		expected := []Role{
+			Role{ID: "9fe2ff9ee4384b1894a90878d3e92bab", Name: "foo_role"},
+			Role{ID: "1ea3d56793574b668e85960fbf651e13", Name: "admin"},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/identity/v2/users/results.go b/openstack/identity/v2/users/results.go
new file mode 100644
index 0000000..c493383
--- /dev/null
+++ b/openstack/identity/v2/users/results.go
@@ -0,0 +1,110 @@
+package users
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// User represents a user resource that exists on the API.
+type User struct {
+	// The UUID for this user.
+	ID string
+
+	// The human name for this user.
+	Name string
+
+	// The username for this user.
+	Username string
+
+	// Indicates whether the user is enabled (true) or disabled (false).
+	Enabled bool
+
+	// The email address for this user.
+	Email string
+
+	// The ID of the tenant to which this user belongs.
+	TenantID string `json:"tenant_id"`
+}
+
+// Role assigns specific responsibilities to users, allowing them to accomplish
+// certain API operations whilst scoped to a service.
+type Role struct {
+	// UUID of the role
+	ID string
+
+	// Name of the role
+	Name string
+}
+
+// UserPage is a single page of a User collection.
+type UserPage struct {
+	pagination.SinglePageBase
+}
+
+// RolePage is a single page of a user Role collection.
+type RolePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (r UserPage) IsEmpty() (bool, error) {
+	users, err := ExtractUsers(r)
+	return len(users) == 0, err
+}
+
+// ExtractUsers returns a slice of Tenants contained in a single page of results.
+func ExtractUsers(r pagination.Page) ([]User, error) {
+	var s struct {
+		Users []User `json:"users"`
+	}
+	err := (r.(UserPage)).ExtractInto(&s)
+	return s.Users, err
+}
+
+// IsEmpty determines whether or not a page of Tenants contains any results.
+func (r RolePage) IsEmpty() (bool, error) {
+	users, err := ExtractRoles(r)
+	return len(users) == 0, err
+}
+
+// ExtractRoles returns a slice of Roles contained in a single page of results.
+func ExtractRoles(r pagination.Page) ([]Role, error) {
+	var s struct {
+		Roles []Role `json:"roles"`
+	}
+	err := (r.(RolePage)).ExtractInto(&s)
+	return s.Roles, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets any commonResult as a User, if possible.
+func (r commonResult) Extract() (*User, error) {
+	var s struct {
+		User *User `json:"user"`
+	}
+	err := r.ExtractInto(&s)
+	return s.User, err
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a Delete operation
+type DeleteResult struct {
+	commonResult
+}
diff --git a/openstack/identity/v2/users/urls.go b/openstack/identity/v2/users/urls.go
new file mode 100644
index 0000000..89f66f2
--- /dev/null
+++ b/openstack/identity/v2/users/urls.go
@@ -0,0 +1,21 @@
+package users
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	tenantPath = "tenants"
+	userPath   = "users"
+	rolePath   = "roles"
+)
+
+func ResourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(userPath, id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(userPath)
+}
+
+func listRolesURL(c *gophercloud.ServiceClient, tenantID, userID string) string {
+	return c.ServiceURL(tenantPath, tenantID, userPath, userID, rolePath)
+}
diff --git a/openstack/identity/v3/endpoints/doc.go b/openstack/identity/v3/endpoints/doc.go
new file mode 100644
index 0000000..8516394
--- /dev/null
+++ b/openstack/identity/v3/endpoints/doc.go
@@ -0,0 +1,6 @@
+// Package endpoints provides information and interaction with the service
+// endpoints API resource in the OpenStack Identity service.
+//
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v3.html#endpoints-v3
+package endpoints
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
new file mode 100644
index 0000000..fc44365
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -0,0 +1,102 @@
+package endpoints
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type CreateOptsBuilder interface {
+	ToEndpointCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains the subset of Endpoint attributes that should be used to create an Endpoint.
+type CreateOpts struct {
+	Availability gophercloud.Availability `json:"interface" required:"true"`
+	Name         string                   `json:"name" required:"true"`
+	Region       string                   `json:"region,omitempty"`
+	URL          string                   `json:"url" required:"true"`
+	ServiceID    string                   `json:"service_id" required:"true"`
+}
+
+func (opts CreateOpts) ToEndpointCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "endpoint")
+}
+
+// Create inserts a new Endpoint into the service catalog.
+// Within EndpointOpts, Region may be omitted by being left as "", but all other fields are required.
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToEndpointCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Post(listURL(client), &b, &r.Body, nil)
+	return
+}
+
+type ListOptsBuilder interface {
+	ToEndpointListParams() (string, error)
+}
+
+// ListOpts allows finer control over the endpoints returned by a List call.
+// All fields are optional.
+type ListOpts struct {
+	Availability gophercloud.Availability `q:"interface"`
+	ServiceID    string                   `q:"service_id"`
+	Page         int                      `q:"page"`
+	PerPage      int                      `q:"per_page"`
+}
+
+func (opts ListOpts) ToEndpointListParams() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	u := listURL(client)
+	if opts != nil {
+		q, err := gophercloud.BuildQueryString(opts)
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		u += q.String()
+	}
+	return pagination.NewPager(client, u, func(r pagination.PageResult) pagination.Page {
+		return EndpointPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+type UpdateOptsBuilder interface {
+	ToEndpointUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the subset of Endpoint attributes that should be used to update an Endpoint.
+type UpdateOpts struct {
+	Availability gophercloud.Availability `json:"interface,omitempty"`
+	Name         string                   `json:"name,omitempty"`
+	Region       string                   `json:"region,omitempty"`
+	URL          string                   `json:"url,omitempty"`
+	ServiceID    string                   `json:"service_id,omitempty"`
+}
+
+func (opts UpdateOpts) ToEndpointUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "endpoint")
+}
+
+// Update changes an existing endpoint with new data.
+func Update(client *gophercloud.ServiceClient, endpointID string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToEndpointUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = client.Patch(endpointURL(client, endpointID), &b, &r.Body, nil)
+	return
+}
+
+// Delete removes an endpoint from the service catalog.
+func Delete(client *gophercloud.ServiceClient, endpointID string) (r DeleteResult) {
+	_, r.Err = client.Delete(endpointURL(client, endpointID), nil)
+	return
+}
diff --git a/openstack/identity/v3/endpoints/requests_test.go b/openstack/identity/v3/endpoints/requests_test.go
new file mode 100644
index 0000000..14bbe6a
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests_test.go
@@ -0,0 +1,213 @@
+package endpoints
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+      {
+        "endpoint": {
+          "interface": "public",
+          "name": "the-endiest-of-points",
+          "region": "underground",
+          "url": "https://1.2.3.4:9000/",
+          "service_id": "asdfasdfasdfasdf"
+        }
+      }
+    `)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `
+      {
+        "endpoint": {
+          "id": "12",
+          "interface": "public",
+          "links": {
+            "self": "https://localhost:5000/v3/endpoints/12"
+          },
+          "name": "the-endiest-of-points",
+          "region": "underground",
+          "service_id": "asdfasdfasdfasdf",
+          "url": "https://1.2.3.4:9000/"
+        }
+      }
+    `)
+	})
+
+	actual, err := Create(client.ServiceClient(), CreateOpts{
+		Availability: gophercloud.AvailabilityPublic,
+		Name:         "the-endiest-of-points",
+		Region:       "underground",
+		URL:          "https://1.2.3.4:9000/",
+		ServiceID:    "asdfasdfasdfasdf",
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Endpoint{
+		ID:           "12",
+		Availability: gophercloud.AvailabilityPublic,
+		Name:         "the-endiest-of-points",
+		Region:       "underground",
+		ServiceID:    "asdfasdfasdfasdf",
+		URL:          "https://1.2.3.4:9000/",
+	}
+
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListEndpoints(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"endpoints": [
+					{
+						"id": "12",
+						"interface": "public",
+						"links": {
+							"self": "https://localhost:5000/v3/endpoints/12"
+						},
+						"name": "the-endiest-of-points",
+						"region": "underground",
+						"service_id": "asdfasdfasdfasdf",
+						"url": "https://1.2.3.4:9000/"
+					},
+					{
+						"id": "13",
+						"interface": "internal",
+						"links": {
+							"self": "https://localhost:5000/v3/endpoints/13"
+						},
+						"name": "shhhh",
+						"region": "underground",
+						"service_id": "asdfasdfasdfasdf",
+						"url": "https://1.2.3.4:9001/"
+					}
+				],
+				"links": {
+					"next": null,
+					"previous": null
+				}
+			}
+		`)
+	})
+
+	count := 0
+	List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractEndpoints(page)
+		if err != nil {
+			t.Errorf("Failed to extract endpoints: %v", err)
+			return false, err
+		}
+
+		expected := []Endpoint{
+			Endpoint{
+				ID:           "12",
+				Availability: gophercloud.AvailabilityPublic,
+				Name:         "the-endiest-of-points",
+				Region:       "underground",
+				ServiceID:    "asdfasdfasdfasdf",
+				URL:          "https://1.2.3.4:9000/",
+			},
+			Endpoint{
+				ID:           "13",
+				Availability: gophercloud.AvailabilityInternal,
+				Name:         "shhhh",
+				Region:       "underground",
+				ServiceID:    "asdfasdfasdfasdf",
+				URL:          "https://1.2.3.4:9001/",
+			},
+		}
+		th.AssertDeepEquals(t, expected, actual)
+		return true, nil
+	})
+	th.AssertEquals(t, 1, count)
+}
+
+func TestUpdateEndpoint(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PATCH")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `
+		{
+	    "endpoint": {
+	      "name": "renamed",
+				"region": "somewhere-else"
+	    }
+		}
+	`)
+
+		fmt.Fprintf(w, `
+		{
+			"endpoint": {
+				"id": "12",
+				"interface": "public",
+				"links": {
+					"self": "https://localhost:5000/v3/endpoints/12"
+				},
+				"name": "renamed",
+				"region": "somewhere-else",
+				"service_id": "asdfasdfasdfasdf",
+				"url": "https://1.2.3.4:9000/"
+			}
+		}
+	`)
+	})
+
+	actual, err := Update(client.ServiceClient(), "12", UpdateOpts{
+		Name:   "renamed",
+		Region: "somewhere-else",
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unexpected error from Update: %v", err)
+	}
+
+	expected := &Endpoint{
+		ID:           "12",
+		Availability: gophercloud.AvailabilityPublic,
+		Name:         "renamed",
+		Region:       "somewhere-else",
+		ServiceID:    "asdfasdfasdfasdf",
+		URL:          "https://1.2.3.4:9000/",
+	}
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteEndpoint(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/endpoints/34", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(client.ServiceClient(), "34")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
new file mode 100644
index 0000000..2788f16
--- /dev/null
+++ b/openstack/identity/v3/endpoints/results.go
@@ -0,0 +1,70 @@
+package endpoints
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets a GetResult, CreateResult or UpdateResult as a concrete Endpoint.
+// An error is returned if the original call or the extraction failed.
+func (r commonResult) Extract() (*Endpoint, error) {
+	var s struct {
+		Endpoint *Endpoint `json:"endpoint"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Endpoint, err
+}
+
+// CreateResult is the deferred result of a Create call.
+type CreateResult struct {
+	commonResult
+}
+
+// createErr quickly wraps an error in a CreateResult.
+func createErr(err error) CreateResult {
+	return CreateResult{commonResult{gophercloud.Result{Err: err}}}
+}
+
+// UpdateResult is the deferred result of an Update call.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult is the deferred result of an Delete call.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Endpoint describes the entry point for another service's API.
+type Endpoint struct {
+	ID           string                   `json:"id"`
+	Availability gophercloud.Availability `json:"interface"`
+	Name         string                   `json:"name"`
+	Region       string                   `json:"region"`
+	ServiceID    string                   `json:"service_id"`
+	URL          string                   `json:"url"`
+}
+
+// EndpointPage is a single page of Endpoint results.
+type EndpointPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if no Endpoints were returned.
+func (r EndpointPage) IsEmpty() (bool, error) {
+	es, err := ExtractEndpoints(r)
+	return len(es) == 0, err
+}
+
+// ExtractEndpoints extracts an Endpoint slice from a Page.
+func ExtractEndpoints(r pagination.Page) ([]Endpoint, error) {
+	var s struct {
+		Endpoints []Endpoint `json:"endpoints"`
+	}
+	err := (r.(EndpointPage)).ExtractInto(&s)
+	return s.Endpoints, err
+}
diff --git a/openstack/identity/v3/endpoints/urls.go b/openstack/identity/v3/endpoints/urls.go
new file mode 100644
index 0000000..80cf57e
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls.go
@@ -0,0 +1,11 @@
+package endpoints
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("endpoints")
+}
+
+func endpointURL(client *gophercloud.ServiceClient, endpointID string) string {
+	return client.ServiceURL("endpoints", endpointID)
+}
diff --git a/openstack/identity/v3/roles/doc.go b/openstack/identity/v3/roles/doc.go
new file mode 100644
index 0000000..bdbc674
--- /dev/null
+++ b/openstack/identity/v3/roles/doc.go
@@ -0,0 +1,3 @@
+// Package roles provides information and interaction with the roles API
+// resource for the OpenStack Identity service.
+package roles
diff --git a/openstack/identity/v3/roles/requests.go b/openstack/identity/v3/roles/requests.go
new file mode 100644
index 0000000..de65c51
--- /dev/null
+++ b/openstack/identity/v3/roles/requests.go
@@ -0,0 +1,47 @@
+package roles
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListAssignmentsOptsBuilder allows extensions to add additional parameters to
+// the ListAssignments request.
+type ListAssignmentsOptsBuilder interface {
+	ToRolesListAssignmentsQuery() (string, error)
+}
+
+// ListAssignmentsOpts allows you to query the ListAssignments method.
+// Specify one of or a combination of GroupId, RoleId, ScopeDomainId, ScopeProjectId,
+// and/or UserId to search for roles assigned to corresponding entities.
+// Effective lists effective assignments at the user, project, and domain level,
+// allowing for the effects of group membership.
+type ListAssignmentsOpts struct {
+	GroupID        string `q:"group.id"`
+	RoleID         string `q:"role.id"`
+	ScopeDomainID  string `q:"scope.domain.id"`
+	ScopeProjectID string `q:"scope.project.id"`
+	UserID         string `q:"user.id"`
+	Effective      *bool  `q:"effective"`
+}
+
+// ToRolesListAssignmentsQuery formats a ListAssignmentsOpts into a query string.
+func (opts ListAssignmentsOpts) ToRolesListAssignmentsQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// ListAssignments enumerates the roles assigned to a specified resource.
+func ListAssignments(client *gophercloud.ServiceClient, opts ListAssignmentsOptsBuilder) pagination.Pager {
+	url := listAssignmentsURL(client)
+	if opts != nil {
+		query, err := opts.ToRolesListAssignmentsQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return RoleAssignmentPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
diff --git a/openstack/identity/v3/roles/requests_test.go b/openstack/identity/v3/roles/requests_test.go
new file mode 100644
index 0000000..ec6531c
--- /dev/null
+++ b/openstack/identity/v3/roles/requests_test.go
@@ -0,0 +1,104 @@
+package roles
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	"github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListSinglePage(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/role_assignments", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+                "role_assignments": [
+                    {
+                        "links": {
+                            "assignment": "http://identity:35357/v3/domains/161718/users/313233/roles/123456"
+                        },
+                        "role": {
+                            "id": "123456"
+                        },
+                        "scope": {
+                            "domain": {
+                                "id": "161718"
+                            }
+                        },
+                        "user": {
+                            "id": "313233"
+                        }
+                    },
+                    {
+                        "links": {
+                            "assignment": "http://identity:35357/v3/projects/456789/groups/101112/roles/123456",
+                            "membership": "http://identity:35357/v3/groups/101112/users/313233"
+                        },
+                        "role": {
+                            "id": "123456"
+                        },
+                        "scope": {
+                            "project": {
+                                "id": "456789"
+                            }
+                        },
+                        "user": {
+                            "id": "313233"
+                        }
+                    }
+                ],
+                "links": {
+                    "self": "http://identity:35357/v3/role_assignments?effective",
+                    "previous": null,
+                    "next": null
+                }
+            }
+		`)
+	})
+
+	count := 0
+	err := ListAssignments(client.ServiceClient(), ListAssignmentsOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractRoleAssignments(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []RoleAssignment{
+			RoleAssignment{
+				Role:  Role{ID: "123456"},
+				Scope: Scope{Domain: Domain{ID: "161718"}},
+				User:  User{ID: "313233"},
+				Group: Group{},
+			},
+			RoleAssignment{
+				Role:  Role{ID: "123456"},
+				Scope: Scope{Project: Project{ID: "456789"}},
+				User:  User{ID: "313233"},
+				Group: Group{},
+			},
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %#v, got %#v", expected, actual)
+		}
+
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("Unexpected error while paging: %v", err)
+	}
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
diff --git a/openstack/identity/v3/roles/results.go b/openstack/identity/v3/roles/results.go
new file mode 100644
index 0000000..e8a3aa9
--- /dev/null
+++ b/openstack/identity/v3/roles/results.go
@@ -0,0 +1,67 @@
+package roles
+
+import "github.com/gophercloud/gophercloud/pagination"
+
+// RoleAssignment is the result of a role assignments query.
+type RoleAssignment struct {
+	Role  Role  `json:"role,omitempty"`
+	Scope Scope `json:"scope,omitempty"`
+	User  User  `json:"user,omitempty"`
+	Group Group `json:"group,omitempty"`
+}
+
+type Role struct {
+	ID string `json:"id,omitempty"`
+}
+
+type Scope struct {
+	Domain  Domain  `json:"domain,omitempty"`
+	Project Project `json:"project,omitempty"`
+}
+
+type Domain struct {
+	ID string `json:"id,omitempty"`
+}
+
+type Project struct {
+	ID string `json:"id,omitempty"`
+}
+
+type User struct {
+	ID string `json:"id,omitempty"`
+}
+
+type Group struct {
+	ID string `json:"id,omitempty"`
+}
+
+// RoleAssignmentPage is a single page of RoleAssignments results.
+type RoleAssignmentPage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (r RoleAssignmentPage) IsEmpty() (bool, error) {
+	roleAssignments, err := ExtractRoleAssignments(r)
+	return len(roleAssignments) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r RoleAssignmentPage) NextPageURL() (string, error) {
+	var s struct {
+		Links struct {
+			Next string `json:"next"`
+		} `json:"links"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Links.Next, err
+}
+
+// ExtractRoleAssignments extracts a slice of RoleAssignments from a Collection acquired from List.
+func ExtractRoleAssignments(r pagination.Page) ([]RoleAssignment, error) {
+	var s struct {
+		RoleAssignments []RoleAssignment `json:"role_assignments"`
+	}
+	err := (r.(RoleAssignmentPage)).ExtractInto(&s)
+	return s.RoleAssignments, err
+}
diff --git a/openstack/identity/v3/roles/urls.go b/openstack/identity/v3/roles/urls.go
new file mode 100644
index 0000000..8d87b6e
--- /dev/null
+++ b/openstack/identity/v3/roles/urls.go
@@ -0,0 +1,7 @@
+package roles
+
+import "github.com/gophercloud/gophercloud"
+
+func listAssignmentsURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("role_assignments")
+}
diff --git a/openstack/identity/v3/services/doc.go b/openstack/identity/v3/services/doc.go
new file mode 100644
index 0000000..fa56411
--- /dev/null
+++ b/openstack/identity/v3/services/doc.go
@@ -0,0 +1,3 @@
+// Package services provides information and interaction with the services API
+// resource for the OpenStack Identity service.
+package services
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
new file mode 100644
index 0000000..bb7bb04
--- /dev/null
+++ b/openstack/identity/v3/services/requests.go
@@ -0,0 +1,64 @@
+package services
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Create adds a new service of the requested type to the catalog.
+func Create(client *gophercloud.ServiceClient, serviceType string) (r CreateResult) {
+	b := map[string]string{"type": serviceType}
+	_, r.Err = client.Post(listURL(client), b, &r.Body, nil)
+	return
+}
+
+type ListOptsBuilder interface {
+	ToServiceListMap() (string, error)
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+	ServiceType string `q:"type"`
+	PerPage     int    `q:"perPage"`
+	Page        int    `q:"page"`
+}
+
+func (opts ListOpts) ToServiceListMap() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List enumerates the services available to a specific user.
+func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	u := listURL(client)
+	if opts != nil {
+		q, err := opts.ToServiceListMap()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		u += q
+	}
+	return pagination.NewPager(client, u, func(r pagination.PageResult) pagination.Page {
+		return ServicePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get returns additional information about a service, given its ID.
+func Get(client *gophercloud.ServiceClient, serviceID string) (r GetResult) {
+	_, r.Err = client.Get(serviceURL(client, serviceID), &r.Body, nil)
+	return
+}
+
+// Update changes the service type of an existing service.
+func Update(client *gophercloud.ServiceClient, serviceID string, serviceType string) (r UpdateResult) {
+	b := map[string]string{"type": serviceType}
+	_, r.Err = client.Patch(serviceURL(client, serviceID), &b, &r.Body, nil)
+	return
+}
+
+// Delete removes an existing service.
+// It either deletes all associated endpoints, or fails until all endpoints are deleted.
+func Delete(client *gophercloud.ServiceClient, serviceID string) (r DeleteResult) {
+	_, r.Err = client.Delete(serviceURL(client, serviceID), nil)
+	return
+}
diff --git a/openstack/identity/v3/services/requests_test.go b/openstack/identity/v3/services/requests_test.go
new file mode 100644
index 0000000..aa19bcc
--- /dev/null
+++ b/openstack/identity/v3/services/requests_test.go
@@ -0,0 +1,186 @@
+package services
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateSuccessful(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "type": "compute" }`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{
+        "service": {
+          "description": "Here's your service",
+          "id": "1234",
+          "name": "InscrutableOpenStackProjectName",
+          "type": "compute"
+        }
+    }`)
+	})
+
+	expected := &Service{
+		Description: "Here's your service",
+		ID:          "1234",
+		Name:        "InscrutableOpenStackProjectName",
+		Type:        "compute",
+	}
+
+	actual, err := Create(client.ServiceClient(), "compute").Extract()
+	if err != nil {
+		t.Fatalf("Unexpected error from Create: %v", err)
+	}
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListSinglePage(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"links": {
+					"next": null,
+					"previous": null
+				},
+				"services": [
+					{
+						"description": "Service One",
+						"id": "1234",
+						"name": "service-one",
+						"type": "identity"
+					},
+					{
+						"description": "Service Two",
+						"id": "9876",
+						"name": "service-two",
+						"type": "compute"
+					}
+				]
+			}
+		`)
+	})
+
+	count := 0
+	err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractServices(page)
+		if err != nil {
+			return false, err
+		}
+
+		expected := []Service{
+			Service{
+				Description: "Service One",
+				ID:          "1234",
+				Name:        "service-one",
+				Type:        "identity",
+			},
+			Service{
+				Description: "Service Two",
+				ID:          "9876",
+				Name:        "service-two",
+				Type:        "compute",
+			},
+		}
+		th.AssertDeepEquals(t, expected, actual)
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, 1, count)
+}
+
+func TestGetSuccessful(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"service": {
+						"description": "Service One",
+						"id": "12345",
+						"name": "service-one",
+						"type": "identity"
+				}
+			}
+		`)
+	})
+
+	actual, err := Get(client.ServiceClient(), "12345").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := &Service{
+		ID:          "12345",
+		Description: "Service One",
+		Name:        "service-one",
+		Type:        "identity",
+	}
+
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateSuccessful(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PATCH")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestJSONRequest(t, r, `{ "type": "lasermagic" }`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"service": {
+						"id": "12345",
+						"type": "lasermagic"
+				}
+			}
+		`)
+	})
+
+	expected := &Service{
+		ID:   "12345",
+		Type: "lasermagic",
+	}
+
+	actual, err := Update(client.ServiceClient(), "12345", "lasermagic").Extract()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestDeleteSuccessful(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(client.ServiceClient(), "12345")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
new file mode 100644
index 0000000..9ebcc20
--- /dev/null
+++ b/openstack/identity/v3/services/results.go
@@ -0,0 +1,68 @@
+package services
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract interprets a GetResult, CreateResult or UpdateResult as a concrete Service.
+// An error is returned if the original call or the extraction failed.
+func (r commonResult) Extract() (*Service, error) {
+	var s struct {
+		Service *Service `json:"service"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Service, err
+}
+
+// CreateResult is the deferred result of a Create call.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult is the deferred result of a Get call.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult is the deferred result of an Update call.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult is the deferred result of an Delete call.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Service is the result of a list or information query.
+type Service struct {
+	Description string `json:"description`
+	ID          string `json:"id"`
+	Name        string `json:"name"`
+	Type        string `json:"type"`
+}
+
+// ServicePage is a single page of Service results.
+type ServicePage struct {
+	pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if the page contains no results.
+func (p ServicePage) IsEmpty() (bool, error) {
+	services, err := ExtractServices(p)
+	return len(services) == 0, err
+}
+
+// ExtractServices extracts a slice of Services from a Collection acquired from List.
+func ExtractServices(r pagination.Page) ([]Service, error) {
+	var s struct {
+		Services []Service `json:"services"`
+	}
+	err := (r.(ServicePage)).ExtractInto(&s)
+	return s.Services, err
+}
diff --git a/openstack/identity/v3/services/urls.go b/openstack/identity/v3/services/urls.go
new file mode 100644
index 0000000..c5ae268
--- /dev/null
+++ b/openstack/identity/v3/services/urls.go
@@ -0,0 +1,11 @@
+package services
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("services")
+}
+
+func serviceURL(client *gophercloud.ServiceClient, serviceID string) string {
+	return client.ServiceURL("services", serviceID)
+}
diff --git a/openstack/identity/v3/tokens/doc.go b/openstack/identity/v3/tokens/doc.go
new file mode 100644
index 0000000..76ff5f4
--- /dev/null
+++ b/openstack/identity/v3/tokens/doc.go
@@ -0,0 +1,6 @@
+// Package tokens provides information and interaction with the token API
+// resource for the OpenStack Identity service.
+//
+// For more information, see:
+// http://developer.openstack.org/api-ref-identity-v3.html#tokens-v3
+package tokens
diff --git a/openstack/identity/v3/tokens/errors.go b/openstack/identity/v3/tokens/errors.go
new file mode 100644
index 0000000..9cc1d59
--- /dev/null
+++ b/openstack/identity/v3/tokens/errors.go
@@ -0,0 +1,139 @@
+package tokens
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+func unacceptedAttributeErr(attribute string) string {
+	return fmt.Sprintf("The base Identity V3 API does not accept authentication by %s", attribute)
+}
+
+func redundantWithTokenErr(attribute string) string {
+	return fmt.Sprintf("%s may not be provided when authenticating with a TokenID", attribute)
+}
+
+func redundantWithUserID(attribute string) string {
+	return fmt.Sprintf("%s may not be provided when authenticating with a UserID", attribute)
+}
+
+// ErrAPIKeyProvided indicates that an APIKey was provided but can't be used.
+type ErrAPIKeyProvided struct{ gophercloud.BaseError }
+
+func (e ErrAPIKeyProvided) Error() string {
+	return unacceptedAttributeErr("APIKey")
+}
+
+// ErrTenantIDProvided indicates that a TenantID was provided but can't be used.
+type ErrTenantIDProvided struct{ gophercloud.BaseError }
+
+func (e ErrTenantIDProvided) Error() string {
+	return unacceptedAttributeErr("TenantID")
+}
+
+// ErrTenantNameProvided indicates that a TenantName was provided but can't be used.
+type ErrTenantNameProvided struct{ gophercloud.BaseError }
+
+func (e ErrTenantNameProvided) Error() string {
+	return unacceptedAttributeErr("TenantName")
+}
+
+// ErrUsernameWithToken indicates that a Username was provided, but token authentication is being used instead.
+type ErrUsernameWithToken struct{ gophercloud.BaseError }
+
+func (e ErrUsernameWithToken) Error() string {
+	return redundantWithTokenErr("Username")
+}
+
+// ErrUserIDWithToken indicates that a UserID was provided, but token authentication is being used instead.
+type ErrUserIDWithToken struct{ gophercloud.BaseError }
+
+func (e ErrUserIDWithToken) Error() string {
+	return redundantWithTokenErr("UserID")
+}
+
+// ErrDomainIDWithToken indicates that a DomainID was provided, but token authentication is being used instead.
+type ErrDomainIDWithToken struct{ gophercloud.BaseError }
+
+func (e ErrDomainIDWithToken) Error() string {
+	return redundantWithTokenErr("DomainID")
+}
+
+// ErrDomainNameWithToken indicates that a DomainName was provided, but token authentication is being used instead.s
+type ErrDomainNameWithToken struct{ gophercloud.BaseError }
+
+func (e ErrDomainNameWithToken) Error() string {
+	return redundantWithTokenErr("DomainName")
+}
+
+// ErrUsernameOrUserID indicates that neither username nor userID are specified, or both are at once.
+type ErrUsernameOrUserID struct{ gophercloud.BaseError }
+
+func (e ErrUsernameOrUserID) Error() string {
+	return "Exactly one of Username and UserID must be provided for password authentication"
+}
+
+// ErrDomainIDWithUserID indicates that a DomainID was provided, but unnecessary because a UserID is being used.
+type ErrDomainIDWithUserID struct{ gophercloud.BaseError }
+
+func (e ErrDomainIDWithUserID) Error() string {
+	return redundantWithUserID("DomainID")
+}
+
+// ErrDomainNameWithUserID indicates that a DomainName was provided, but unnecessary because a UserID is being used.
+type ErrDomainNameWithUserID struct{ gophercloud.BaseError }
+
+func (e ErrDomainNameWithUserID) Error() string {
+	return redundantWithUserID("DomainName")
+}
+
+// ErrDomainIDOrDomainName indicates that a username was provided, but no domain to scope it.
+// It may also indicate that both a DomainID and a DomainName were provided at once.
+type ErrDomainIDOrDomainName struct{ gophercloud.BaseError }
+
+func (e ErrDomainIDOrDomainName) Error() string {
+	return "You must provide exactly one of DomainID or DomainName to authenticate by Username"
+}
+
+// ErrMissingPassword indicates that no password was provided and no token is available.
+type ErrMissingPassword struct{ gophercloud.BaseError }
+
+func (e ErrMissingPassword) Error() string {
+	return "You must provide a password to authenticate"
+}
+
+// ErrScopeDomainIDOrDomainName indicates that a domain ID or Name was required in a Scope, but not present.
+type ErrScopeDomainIDOrDomainName struct{ gophercloud.BaseError }
+
+func (e ErrScopeDomainIDOrDomainName) Error() string {
+	return "You must provide exactly one of DomainID or DomainName in a Scope with ProjectName"
+}
+
+// ErrScopeProjectIDOrProjectName indicates that both a ProjectID and a ProjectName were provided in a Scope.
+type ErrScopeProjectIDOrProjectName struct{ gophercloud.BaseError }
+
+func (e ErrScopeProjectIDOrProjectName) Error() string {
+	return "You must provide at most one of ProjectID or ProjectName in a Scope"
+}
+
+// ErrScopeProjectIDAlone indicates that a ProjectID was provided with other constraints in a Scope.
+type ErrScopeProjectIDAlone struct{ gophercloud.BaseError }
+
+func (e ErrScopeProjectIDAlone) Error() string {
+	return "ProjectID must be supplied alone in a Scope"
+}
+
+// ErrScopeDomainName indicates that a DomainName was provided alone in a Scope.
+type ErrScopeDomainName struct{ gophercloud.BaseError }
+
+func (e ErrScopeDomainName) Error() string {
+	return "DomainName must be supplied with a ProjectName or ProjectID in a Scope"
+}
+
+// ErrScopeEmpty indicates that no credentials were provided in a Scope.
+type ErrScopeEmpty struct{ gophercloud.BaseError }
+
+func (e ErrScopeEmpty) Error() string {
+	return "You must provide either a Project or Domain in a Scope"
+}
diff --git a/openstack/identity/v3/tokens/requests.go b/openstack/identity/v3/tokens/requests.go
new file mode 100644
index 0000000..12930f9
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests.go
@@ -0,0 +1,328 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+// Scope allows a created token to be limited to a specific domain or project.
+type Scope struct {
+	ProjectID   string `json:"scope.project.id,omitempty" not:"ProjectName,DomainID,DomainName"`
+	ProjectName string `json:"scope.project.name,omitempty"`
+	DomainID    string `json:"scope.project.id,omitempty" not:"ProjectName,ProjectID,DomainName"`
+	DomainName  string `json:"scope.project.id,omitempty"`
+}
+
+// AuthOptionsBuilder describes any argument that may be passed to the Create call.
+type AuthOptionsBuilder interface {
+	// ToTokenV3CreateMap assembles the Create request body, returning an error if parameters are
+	// missing or inconsistent.
+	ToTokenV3CreateMap(*Scope) (map[string]interface{}, error)
+}
+
+type AuthOptions struct {
+	// IdentityEndpoint specifies the HTTP endpoint that is required to work with
+	// the Identity API of the appropriate version. While it's ultimately needed by
+	// all of the identity services, it will often be populated by a provider-level
+	// function.
+	IdentityEndpoint string `json:"-"`
+
+	// Username is required if using Identity V2 API. Consult with your provider's
+	// control panel to discover your account's username. In Identity V3, either
+	// UserID or a combination of Username and DomainID or DomainName are needed.
+	Username string `json:"username,omitempty"`
+	UserID   string `json:"id,omitempty"`
+
+	Password string `json:"password,omitempty"`
+
+	// At most one of DomainID and DomainName must be provided if using Username
+	// with Identity V3. Otherwise, either are optional.
+	DomainID   string `json:"id,omitempty"`
+	DomainName string `json:"name,omitempty"`
+
+	// The TenantID and TenantName fields are optional for the Identity V2 API.
+	// Some providers allow you to specify a TenantName instead of the TenantId.
+	// Some require both. Your provider's authentication policies will determine
+	// how these fields influence authentication.
+	TenantID   string `json:"tenantId,omitempty"`
+	TenantName string `json:"tenantName,omitempty"`
+
+	// AllowReauth should be set to true if you grant permission for Gophercloud to
+	// cache your credentials in memory, and to allow Gophercloud to attempt to
+	// re-authenticate automatically if/when your token expires.  If you set it to
+	// false, it will not cache these settings, but re-authentication will not be
+	// possible.  This setting defaults to false.
+	AllowReauth bool `json:"-"`
+
+	// TokenID allows users to authenticate (possibly as another user) with an
+	// authentication token ID.
+	TokenID string
+}
+
+func (opts AuthOptions) ToTokenV3CreateMap(scope *Scope) (map[string]interface{}, error) {
+	type domainReq struct {
+		ID   *string `json:"id,omitempty"`
+		Name *string `json:"name,omitempty"`
+	}
+
+	type projectReq struct {
+		Domain *domainReq `json:"domain,omitempty"`
+		Name   *string    `json:"name,omitempty"`
+		ID     *string    `json:"id,omitempty"`
+	}
+
+	type userReq struct {
+		ID       *string    `json:"id,omitempty"`
+		Name     *string    `json:"name,omitempty"`
+		Password string     `json:"password"`
+		Domain   *domainReq `json:"domain,omitempty"`
+	}
+
+	type passwordReq struct {
+		User userReq `json:"user"`
+	}
+
+	type tokenReq struct {
+		ID string `json:"id"`
+	}
+
+	type identityReq struct {
+		Methods  []string     `json:"methods"`
+		Password *passwordReq `json:"password,omitempty"`
+		Token    *tokenReq    `json:"token,omitempty"`
+	}
+
+	type scopeReq struct {
+		Domain  *domainReq  `json:"domain,omitempty"`
+		Project *projectReq `json:"project,omitempty"`
+	}
+
+	type authReq struct {
+		Identity identityReq `json:"identity"`
+		Scope    *scopeReq   `json:"scope,omitempty"`
+	}
+
+	type request struct {
+		Auth authReq `json:"auth"`
+	}
+
+	// Populate the request structure based on the provided arguments. Create and return an error
+	// if insufficient or incompatible information is present.
+	var req request
+
+	// Test first for unrecognized arguments.
+	if opts.TenantID != "" {
+		return nil, ErrTenantIDProvided{}
+	}
+	if opts.TenantName != "" {
+		return nil, ErrTenantNameProvided{}
+	}
+
+	if opts.Password == "" {
+		if opts.TokenID != "" {
+			// Because we aren't using password authentication, it's an error to also provide any of the user-based authentication
+			// parameters.
+			if opts.Username != "" {
+				return nil, ErrUsernameWithToken{}
+			}
+			if opts.UserID != "" {
+				return nil, ErrUserIDWithToken{}
+			}
+			if opts.DomainID != "" {
+				return nil, ErrDomainIDWithToken{}
+			}
+			if opts.DomainName != "" {
+				return nil, ErrDomainNameWithToken{}
+			}
+
+			// Configure the request for Token authentication.
+			req.Auth.Identity.Methods = []string{"token"}
+			req.Auth.Identity.Token = &tokenReq{
+				ID: opts.TokenID,
+			}
+		} else {
+			// If no password or token ID are available, authentication can't continue.
+			return nil, ErrMissingPassword{}
+		}
+	} else {
+		// Password authentication.
+		req.Auth.Identity.Methods = []string{"password"}
+
+		// At least one of Username and UserID must be specified.
+		if opts.Username == "" && opts.UserID == "" {
+			return nil, ErrUsernameOrUserID{}
+		}
+
+		if opts.Username != "" {
+			// If Username is provided, UserID may not be provided.
+			if opts.UserID != "" {
+				return nil, ErrUsernameOrUserID{}
+			}
+
+			// Either DomainID or DomainName must also be specified.
+			if opts.DomainID == "" && opts.DomainName == "" {
+				return nil, ErrDomainIDOrDomainName{}
+			}
+
+			if opts.DomainID != "" {
+				if opts.DomainName != "" {
+					return nil, ErrDomainIDOrDomainName{}
+				}
+
+				// Configure the request for Username and Password authentication with a DomainID.
+				req.Auth.Identity.Password = &passwordReq{
+					User: userReq{
+						Name:     &opts.Username,
+						Password: opts.Password,
+						Domain:   &domainReq{ID: &opts.DomainID},
+					},
+				}
+			}
+
+			if opts.DomainName != "" {
+				// Configure the request for Username and Password authentication with a DomainName.
+				req.Auth.Identity.Password = &passwordReq{
+					User: userReq{
+						Name:     &opts.Username,
+						Password: opts.Password,
+						Domain:   &domainReq{Name: &opts.DomainName},
+					},
+				}
+			}
+		}
+
+		if opts.UserID != "" {
+			// If UserID is specified, neither DomainID nor DomainName may be.
+			if opts.DomainID != "" {
+				return nil, ErrDomainIDWithUserID{}
+			}
+			if opts.DomainName != "" {
+				return nil, ErrDomainNameWithUserID{}
+			}
+
+			// Configure the request for UserID and Password authentication.
+			req.Auth.Identity.Password = &passwordReq{
+				User: userReq{ID: &opts.UserID, Password: opts.Password},
+			}
+		}
+	}
+
+	// Add a "scope" element if a Scope has been provided.
+	if scope != nil {
+		if scope.ProjectName != "" {
+			// ProjectName provided: either DomainID or DomainName must also be supplied.
+			// ProjectID may not be supplied.
+			if scope.DomainID == "" && scope.DomainName == "" {
+				return nil, ErrScopeDomainIDOrDomainName{}
+			}
+			if scope.ProjectID != "" {
+				return nil, ErrScopeProjectIDOrProjectName{}
+			}
+
+			if scope.DomainID != "" {
+				// ProjectName + DomainID
+				req.Auth.Scope = &scopeReq{
+					Project: &projectReq{
+						Name:   &scope.ProjectName,
+						Domain: &domainReq{ID: &scope.DomainID},
+					},
+				}
+			}
+
+			if scope.DomainName != "" {
+				// ProjectName + DomainName
+				req.Auth.Scope = &scopeReq{
+					Project: &projectReq{
+						Name:   &scope.ProjectName,
+						Domain: &domainReq{Name: &scope.DomainName},
+					},
+				}
+			}
+		} else if scope.ProjectID != "" {
+			// ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+			if scope.DomainID != "" {
+				return nil, ErrScopeProjectIDAlone{}
+			}
+			if scope.DomainName != "" {
+				return nil, ErrScopeProjectIDAlone{}
+			}
+
+			// ProjectID
+			req.Auth.Scope = &scopeReq{
+				Project: &projectReq{ID: &scope.ProjectID},
+			}
+		} else if scope.DomainID != "" {
+			// DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+			if scope.DomainName != "" {
+				return nil, ErrScopeDomainIDOrDomainName{}
+			}
+
+			// DomainID
+			req.Auth.Scope = &scopeReq{
+				Domain: &domainReq{ID: &scope.DomainID},
+			}
+		} else if scope.DomainName != "" {
+			return nil, ErrScopeDomainName{}
+		} else {
+			return nil, ErrScopeEmpty{}
+		}
+	}
+
+	b, err2 := gophercloud.BuildRequestBody(req, "")
+	if err2 != nil {
+		return nil, err2
+	}
+	return b, nil
+}
+
+func subjectTokenHeaders(c *gophercloud.ServiceClient, subjectToken string) map[string]string {
+	return map[string]string{
+		"X-Subject-Token": subjectToken,
+	}
+}
+
+// Create authenticates and either generates a new token, or changes the Scope of an existing token.
+func Create(c *gophercloud.ServiceClient, opts AuthOptionsBuilder, scopeOpts *Scope) (r CreateResult) {
+	b, err := opts.ToTokenV3CreateMap(scopeOpts)
+	if err != nil {
+		r.Err = err
+		return
+	}
+	resp, err := c.Post(tokenURL(c), b, &r.Body, nil)
+	if resp != nil {
+		r.Err = err
+		r.Header = resp.Header
+	}
+	return
+}
+
+// Get validates and retrieves information about another token.
+func Get(c *gophercloud.ServiceClient, token string) (r GetResult) {
+	resp, err := c.Get(tokenURL(c), &r.Body, &gophercloud.RequestOpts{
+		MoreHeaders: subjectTokenHeaders(c, token),
+		OkCodes:     []int{200, 203},
+	})
+	if resp != nil {
+		r.Err = err
+		r.Header = resp.Header
+	}
+	return
+}
+
+// Validate determines if a specified token is valid or not.
+func Validate(c *gophercloud.ServiceClient, token string) (bool, error) {
+	resp, err := c.Request("HEAD", tokenURL(c), &gophercloud.RequestOpts{
+		MoreHeaders: subjectTokenHeaders(c, token),
+		OkCodes:     []int{204, 404},
+	})
+	if err != nil {
+		return false, err
+	}
+
+	return resp.StatusCode == 204, nil
+}
+
+// Revoke immediately makes specified token invalid.
+func Revoke(c *gophercloud.ServiceClient, token string) (r RevokeResult) {
+	_, r.Err = c.Delete(tokenURL(c), &gophercloud.RequestOpts{
+		MoreHeaders: subjectTokenHeaders(c, token),
+	})
+	return
+}
diff --git a/openstack/identity/v3/tokens/requests_test.go b/openstack/identity/v3/tokens/requests_test.go
new file mode 100644
index 0000000..faa79e0
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests_test.go
@@ -0,0 +1,508 @@
+package tokens
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+// authTokenPost verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func authTokenPost(t *testing.T, options AuthOptions, scope *Scope, requestJSON string) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{},
+		Endpoint:       testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "POST")
+		testhelper.TestHeader(t, r, "Content-Type", "application/json")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestJSONRequest(t, r, requestJSON)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{
+			"token": {
+				"expires_at": "2014-10-02T13:45:00.000000Z"
+			}
+		}`)
+	})
+
+	_, err := Create(&client, options, scope).Extract()
+	if err != nil {
+		t.Errorf("Create returned an error: %v", err)
+	}
+}
+
+func authTokenPostErr(t *testing.T, options AuthOptions, scope *Scope, includeToken bool, expectedErr error) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{},
+		Endpoint:       testhelper.Endpoint(),
+	}
+	if includeToken {
+		client.TokenID = "abcdef123456"
+	}
+
+	_, err := Create(&client, options, scope).Extract()
+	if err == nil {
+		t.Errorf("Create did NOT return an error")
+	}
+	if err != expectedErr {
+		t.Errorf("Create returned an unexpected error: wanted %v, got %v", expectedErr, err)
+	}
+}
+
+func TestCreateUserIDAndPassword(t *testing.T) {
+	authTokenPost(t, AuthOptions{UserID: "me", Password: "squirrel!"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": { "id": "me", "password": "squirrel!" }
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateUsernameDomainIDPassword(t *testing.T) {
+	authTokenPost(t, AuthOptions{Username: "fakey", Password: "notpassword", DomainID: "abc123"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"domain": {
+								"id": "abc123"
+							},
+							"name": "fakey",
+							"password": "notpassword"
+						}
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateUsernameDomainNamePassword(t *testing.T) {
+	authTokenPost(t, AuthOptions{Username: "frank", Password: "swordfish", DomainName: "spork.net"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"domain": {
+								"name": "spork.net"
+							},
+							"name": "frank",
+							"password": "swordfish"
+						}
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateTokenID(t *testing.T) {
+	authTokenPost(t, AuthOptions{TokenID: "12345abcdef"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["token"],
+					"token": {
+						"id": "12345abcdef"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectIDScope(t *testing.T) {
+	options := AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectID: "123456"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"id": "123456"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateDomainIDScope(t *testing.T) {
+	options := AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{DomainID: "1000"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"domain": {
+						"id": "1000"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectNameAndDomainIDScope(t *testing.T) {
+	options := AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectName: "world-domination", DomainID: "1000"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"domain": {
+							"id": "1000"
+						},
+						"name": "world-domination"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectNameAndDomainNameScope(t *testing.T) {
+	options := AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectName: "world-domination", DomainName: "evil-plans"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"domain": {
+							"name": "evil-plans"
+						},
+						"name": "world-domination"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateExtractsTokenFromResponse(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{},
+		Endpoint:       testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("X-Subject-Token", "aaa111")
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{
+			"token": {
+				"expires_at": "2014-10-02T13:45:00.000000Z"
+			}
+		}`)
+	})
+
+	options := AuthOptions{UserID: "me", Password: "shhh"}
+	token, err := Create(&client, options, nil).Extract()
+	if err != nil {
+		t.Fatalf("Create returned an error: %v", err)
+	}
+
+	if token.ID != "aaa111" {
+		t.Errorf("Expected token to be aaa111, but was %s", token.ID)
+	}
+}
+
+func TestCreateFailureEmptyAuth(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{}, nil, false, ErrMissingPassword{})
+}
+
+func TestCreateFailureTenantID(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{TenantID: "something"}, nil, false, ErrTenantIDProvided{})
+}
+
+func TestCreateFailureTenantName(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{TenantName: "something"}, nil, false, ErrTenantNameProvided{})
+}
+
+func TestCreateFailureTokenIDUsername(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{Username: "something", TokenID: "12345"}, nil, true, ErrUsernameWithToken{})
+}
+
+func TestCreateFailureTokenIDUserID(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{UserID: "something", TokenID: "12345"}, nil, true, ErrUserIDWithToken{})
+}
+
+func TestCreateFailureTokenIDDomainID(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{DomainID: "something", TokenID: "12345"}, nil, true, ErrDomainIDWithToken{})
+}
+
+func TestCreateFailureTokenIDDomainName(t *testing.T) {
+	authTokenPostErr(t, AuthOptions{DomainName: "something", TokenID: "12345"}, nil, true, ErrDomainNameWithToken{})
+}
+
+func TestCreateFailureMissingUser(t *testing.T) {
+	options := AuthOptions{Password: "supersecure"}
+	authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID{})
+}
+
+func TestCreateFailureBothUser(t *testing.T) {
+	options := AuthOptions{
+		Password: "supersecure",
+		Username: "oops",
+		UserID:   "redundancy",
+	}
+	authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID{})
+}
+
+func TestCreateFailureMissingDomain(t *testing.T) {
+	options := AuthOptions{
+		Password: "supersecure",
+		Username: "notuniqueenough",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName{})
+}
+
+func TestCreateFailureBothDomain(t *testing.T) {
+	options := AuthOptions{
+		Password:   "supersecure",
+		Username:   "someone",
+		DomainID:   "hurf",
+		DomainName: "durf",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName{})
+}
+
+func TestCreateFailureUserIDDomainID(t *testing.T) {
+	options := AuthOptions{
+		UserID:   "100",
+		Password: "stuff",
+		DomainID: "oops",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDWithUserID{})
+}
+
+func TestCreateFailureUserIDDomainName(t *testing.T) {
+	options := AuthOptions{
+		UserID:     "100",
+		Password:   "sssh",
+		DomainName: "oops",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainNameWithUserID{})
+}
+
+func TestCreateFailureScopeProjectNameAlone(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectName: "notenough"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName{})
+}
+
+func TestCreateFailureScopeProjectNameAndID(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectName: "whoops", ProjectID: "toomuch", DomainID: "1234"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDOrProjectName{})
+}
+
+func TestCreateFailureScopeProjectIDAndDomainID(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectID: "toomuch", DomainID: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone{})
+}
+
+func TestCreateFailureScopeProjectIDAndDomainNAme(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectID: "toomuch", DomainName: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone{})
+}
+
+func TestCreateFailureScopeDomainIDAndDomainName(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{DomainID: "toomuch", DomainName: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName{})
+}
+
+func TestCreateFailureScopeDomainNameAlone(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{DomainName: "notenough"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainName{})
+}
+
+func TestCreateFailureEmptyScope(t *testing.T) {
+	options := AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{}
+	authTokenPostErr(t, options, scope, false, ErrScopeEmpty{})
+}
+
+func TestGetRequest(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{
+			TokenID: "12345abcdef",
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "Content-Type", "")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+		testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+			{ "token": { "expires_at": "2014-08-29T13:10:01.000000Z" } }
+		`)
+	})
+
+	token, err := Get(&client, "abcdef12345").Extract()
+	if err != nil {
+		t.Errorf("Info returned an error: %v", err)
+	}
+
+	expected, _ := time.Parse(time.UnixDate, "Fri Aug 29 13:10:01 UTC 2014")
+	if token.ExpiresAt != expected {
+		t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), token.ExpiresAt.Format(time.UnixDate))
+	}
+}
+
+func prepareAuthTokenHandler(t *testing.T, expectedMethod string, status int) gophercloud.ServiceClient {
+	client := gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{
+			TokenID: "12345abcdef",
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, expectedMethod)
+		testhelper.TestHeader(t, r, "Content-Type", "")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+		testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+		w.WriteHeader(status)
+	})
+
+	return client
+}
+
+func TestValidateRequestSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusNoContent)
+
+	ok, err := Validate(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Unexpected error from Validate: %v", err)
+	}
+
+	if !ok {
+		t.Errorf("Validate returned false for a valid token")
+	}
+}
+
+func TestValidateRequestFailure(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusNotFound)
+
+	ok, err := Validate(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Unexpected error from Validate: %v", err)
+	}
+
+	if ok {
+		t.Errorf("Validate returned true for an invalid token")
+	}
+}
+
+func TestValidateRequestError(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusUnauthorized)
+
+	_, err := Validate(&client, "abcdef12345")
+	if err == nil {
+		t.Errorf("Missing expected error from Validate")
+	}
+}
+
+func TestRevokeRequestSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "DELETE", http.StatusNoContent)
+
+	res := Revoke(&client, "abcdef12345")
+	testhelper.AssertNoErr(t, res.Err)
+}
+
+func TestRevokeRequestError(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "DELETE", http.StatusNotFound)
+
+	res := Revoke(&client, "abcdef12345")
+	if res.Err == nil {
+		t.Errorf("Missing expected error from Revoke")
+	}
+}
diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go
new file mode 100644
index 0000000..7dd2c0b
--- /dev/null
+++ b/openstack/identity/v3/tokens/results.go
@@ -0,0 +1,125 @@
+package tokens
+
+import (
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// Endpoint represents a single API endpoint offered by a service.
+// It matches either a public, internal or admin URL.
+// If supported, it contains a region specifier, again if provided.
+// The significance of the Region field will depend upon your provider.
+type Endpoint struct {
+	ID        string `json:"id"`
+	Region    string `json:"region"`
+	Interface string `json:"interface"`
+	URL       string `json:"url"`
+}
+
+// CatalogEntry provides a type-safe interface to an Identity API V3 service catalog listing.
+// Each class of service, such as cloud DNS or block storage services, could have multiple
+// CatalogEntry representing it (one by interface type, e.g public, admin or internal).
+//
+// Note: when looking for the desired service, try, whenever possible, to key off the type field.
+// Otherwise, you'll tie the representation of the service to a specific provider.
+type CatalogEntry struct {
+
+	// Service ID
+	ID string `json:"id"`
+
+	// Name will contain the provider-specified name for the service.
+	Name string `json:"name"`
+
+	// Type will contain a type string if OpenStack defines a type for the service.
+	// Otherwise, for provider-specific services, the provider may assign their own type strings.
+	Type string `json:"type"`
+
+	// Endpoints will let the caller iterate over all the different endpoints that may exist for
+	// the service.
+	Endpoints []Endpoint `json:"endpoints"`
+}
+
+// ServiceCatalog provides a view into the service catalog from a previous, successful authentication.
+type ServiceCatalog struct {
+	Entries []CatalogEntry
+}
+
+// commonResult is the deferred result of a Create or a Get call.
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a shortcut for ExtractToken.
+// This function is deprecated and still present for backward compatibility.
+func (r commonResult) Extract() (*Token, error) {
+	return r.ExtractToken()
+}
+
+// ExtractToken interprets a commonResult as a Token.
+func (r commonResult) ExtractToken() (*Token, error) {
+	var s struct {
+		Token struct {
+			ExpiresAt string `json:"expires_at"`
+		} `json:"token"`
+	}
+
+	var token Token
+
+	// Parse the token itself from the stored headers.
+	token.ID = r.Header.Get("X-Subject-Token")
+
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return nil, err
+	}
+
+	// Attempt to parse the timestamp.
+	token.ExpiresAt, err = time.Parse(gophercloud.RFC3339Milli, s.Token.ExpiresAt)
+
+	return &token, err
+}
+
+// ExtractServiceCatalog returns the ServiceCatalog that was generated along with the user's Token.
+func (r CreateResult) ExtractServiceCatalog() (*ServiceCatalog, error) {
+	var s struct {
+		Token struct {
+			Entries []CatalogEntry `json:"catalog"`
+		} `json:"token"`
+	}
+	err := r.ExtractInto(&s)
+	return &ServiceCatalog{Entries: s.Token.Entries}, err
+}
+
+// CreateResult defers the interpretation of a created token.
+// Use ExtractToken() to interpret it as a Token, or ExtractServiceCatalog() to interpret it as a service catalog.
+type CreateResult struct {
+	commonResult
+}
+
+// createErr quickly creates a CreateResult that reports an error.
+func createErr(err error) CreateResult {
+	return CreateResult{
+		commonResult: commonResult{Result: gophercloud.Result{Err: err}},
+	}
+}
+
+// GetResult is the deferred response from a Get call.
+type GetResult struct {
+	commonResult
+}
+
+// RevokeResult is the deferred response from a Revoke call.
+type RevokeResult struct {
+	commonResult
+}
+
+// Token is a string that grants a user access to a controlled set of services in an OpenStack provider.
+// Each Token is valid for a set length of time.
+type Token struct {
+	// ID is the issued token.
+	ID string
+
+	// ExpiresAt is the timestamp at which this token will no longer be accepted.
+	ExpiresAt time.Time
+}
diff --git a/openstack/identity/v3/tokens/urls.go b/openstack/identity/v3/tokens/urls.go
new file mode 100644
index 0000000..2f864a3
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls.go
@@ -0,0 +1,7 @@
+package tokens
+
+import "github.com/gophercloud/gophercloud"
+
+func tokenURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("auth", "tokens")
+}
diff --git a/openstack/networking/v2/apiversions/doc.go b/openstack/networking/v2/apiversions/doc.go
new file mode 100644
index 0000000..0208ee2
--- /dev/null
+++ b/openstack/networking/v2/apiversions/doc.go
@@ -0,0 +1,4 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Neutron service. This functionality is not
+// restricted to this particular version.
+package apiversions
diff --git a/openstack/networking/v2/apiversions/errors.go b/openstack/networking/v2/apiversions/errors.go
new file mode 100644
index 0000000..76bdb14
--- /dev/null
+++ b/openstack/networking/v2/apiversions/errors.go
@@ -0,0 +1 @@
+package apiversions
diff --git a/openstack/networking/v2/apiversions/requests.go b/openstack/networking/v2/apiversions/requests.go
new file mode 100644
index 0000000..59ece85
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests.go
@@ -0,0 +1,21 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListVersions lists all the Neutron API versions available to end-users
+func ListVersions(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, apiVersionsURL(c), func(r pagination.PageResult) pagination.Page {
+		return APIVersionPage{pagination.SinglePageBase(r)}
+	})
+}
+
+// ListVersionResources lists all of the different API resources for a particular
+// API versions. Typical resources for Neutron might be: networks, subnets, etc.
+func ListVersionResources(c *gophercloud.ServiceClient, v string) pagination.Pager {
+	return pagination.NewPager(c, apiInfoURL(c, v), func(r pagination.PageResult) pagination.Page {
+		return APIVersionResourcePage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/networking/v2/apiversions/requests_test.go b/openstack/networking/v2/apiversions/requests_test.go
new file mode 100644
index 0000000..375bfa6
--- /dev/null
+++ b/openstack/networking/v2/apiversions/requests_test.go
@@ -0,0 +1,182 @@
+package apiversions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "versions": [
+        {
+            "status": "CURRENT",
+            "id": "v2.0",
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0",
+                    "rel": "self"
+                }
+            ]
+        }
+    ]
+}`)
+	})
+
+	count := 0
+
+	ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractAPIVersions(page)
+		if err != nil {
+			t.Errorf("Failed to extract API versions: %v", err)
+			return false, err
+		}
+
+		expected := []APIVersion{
+			APIVersion{
+				Status: "CURRENT",
+				ID:     "v2.0",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		if _, err := ExtractAPIVersions(page); err == nil {
+			t.Fatalf("Expected error, got nil")
+		}
+		return true, nil
+	})
+}
+
+func TestAPIInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "resources": [
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/subnets",
+                    "rel": "self"
+                }
+            ],
+            "name": "subnet",
+            "collection": "subnets"
+        },
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/networks",
+                    "rel": "self"
+                }
+            ],
+            "name": "network",
+            "collection": "networks"
+        },
+        {
+            "links": [
+                {
+                    "href": "http://23.253.228.211:9696/v2.0/ports",
+                    "rel": "self"
+                }
+            ],
+            "name": "port",
+            "collection": "ports"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVersionResources(page)
+		if err != nil {
+			t.Errorf("Failed to extract version resources: %v", err)
+			return false, err
+		}
+
+		expected := []APIVersionResource{
+			APIVersionResource{
+				Name:       "subnet",
+				Collection: "subnets",
+			},
+			APIVersionResource{
+				Name:       "network",
+				Collection: "networks",
+			},
+			APIVersionResource{
+				Name:       "port",
+				Collection: "ports",
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersionResources(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	ListVersionResources(fake.ServiceClient(), "v2.0").EachPage(func(page pagination.Page) (bool, error) {
+		if _, err := ExtractVersionResources(page); err == nil {
+			t.Fatalf("Expected error, got nil")
+		}
+		return true, nil
+	})
+}
diff --git a/openstack/networking/v2/apiversions/results.go b/openstack/networking/v2/apiversions/results.go
new file mode 100644
index 0000000..eff4485
--- /dev/null
+++ b/openstack/networking/v2/apiversions/results.go
@@ -0,0 +1,66 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Neutron. It contains the status of
+// the API, and its unique ID.
+type APIVersion struct {
+	Status string `son:"status"`
+	ID     string `json:"id"`
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+	is, err := ExtractAPIVersions(r)
+	return len(is) == 0, err
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(r pagination.Page) ([]APIVersion, error) {
+	var s struct {
+		Versions []APIVersion `json:"versions"`
+	}
+	err := (r.(APIVersionPage)).ExtractInto(&s)
+	return s.Versions, err
+}
+
+// APIVersionResource represents a generic API resource. It contains the name
+// of the resource and its plural collection name.
+type APIVersionResource struct {
+	Name       string `json:"name"`
+	Collection string `json:"collection"`
+}
+
+// APIVersionResourcePage is a concrete type which embeds the common
+// SinglePageBase struct, and is used when traversing API versions collections.
+type APIVersionResourcePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty is a concrete function which indicates whether an
+// APIVersionResourcePage is empty or not.
+func (r APIVersionResourcePage) IsEmpty() (bool, error) {
+	is, err := ExtractVersionResources(r)
+	return len(is) == 0, err
+}
+
+// ExtractVersionResources accepts a Page struct, specifically a
+// APIVersionResourcePage struct, and extracts the elements into a slice of
+// APIVersionResource structs. In other words, the collection is mapped into
+// a relevant slice.
+func ExtractVersionResources(r pagination.Page) ([]APIVersionResource, error) {
+	var s struct {
+		APIVersionResources []APIVersionResource `json:"resources"`
+	}
+	err := (r.(APIVersionResourcePage)).ExtractInto(&s)
+	return s.APIVersionResources, err
+}
diff --git a/openstack/networking/v2/apiversions/urls.go b/openstack/networking/v2/apiversions/urls.go
new file mode 100644
index 0000000..0fa7437
--- /dev/null
+++ b/openstack/networking/v2/apiversions/urls.go
@@ -0,0 +1,15 @@
+package apiversions
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+func apiVersionsURL(c *gophercloud.ServiceClient) string {
+	return c.Endpoint
+}
+
+func apiInfoURL(c *gophercloud.ServiceClient, version string) string {
+	return c.Endpoint + strings.TrimRight(version, "/") + "/"
+}
diff --git a/openstack/networking/v2/common/common_tests.go b/openstack/networking/v2/common/common_tests.go
new file mode 100644
index 0000000..7e1d917
--- /dev/null
+++ b/openstack/networking/v2/common/common_tests.go
@@ -0,0 +1,14 @@
+package common
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+const TokenID = client.TokenID
+
+func ServiceClient() *gophercloud.ServiceClient {
+	sc := client.ServiceClient()
+	sc.ResourceBase = sc.Endpoint + "v2.0/"
+	return sc
+}
diff --git a/openstack/networking/v2/extensions/delegate.go b/openstack/networking/v2/extensions/delegate.go
new file mode 100644
index 0000000..0c43689
--- /dev/null
+++ b/openstack/networking/v2/extensions/delegate.go
@@ -0,0 +1,41 @@
+package extensions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Extension is a single OpenStack extension.
+type Extension struct {
+	common.Extension
+}
+
+// GetResult wraps a GetResult from common.
+type GetResult struct {
+	common.GetResult
+}
+
+// ExtractExtensions interprets a Page as a slice of Extensions.
+func ExtractExtensions(page pagination.Page) ([]Extension, error) {
+	inner, err := common.ExtractExtensions(page)
+	if err != nil {
+		return nil, err
+	}
+	outer := make([]Extension, len(inner))
+	for index, ext := range inner {
+		outer[index] = Extension{ext}
+	}
+	return outer, nil
+}
+
+// Get retrieves information for a specific extension using its alias.
+func Get(c *gophercloud.ServiceClient, alias string) GetResult {
+	return GetResult{common.Get(c, alias)}
+}
+
+// List returns a Pager which allows you to iterate over the full collection of extensions.
+// It does not accept query parameters.
+func List(c *gophercloud.ServiceClient) pagination.Pager {
+	return common.List(c)
+}
diff --git a/openstack/networking/v2/extensions/delegate_test.go b/openstack/networking/v2/extensions/delegate_test.go
new file mode 100755
index 0000000..26ab0ef
--- /dev/null
+++ b/openstack/networking/v2/extensions/delegate_test.go
@@ -0,0 +1,105 @@
+package extensions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	common "github.com/gophercloud/gophercloud/openstack/common/extensions"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/extensions", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+
+		fmt.Fprintf(w, `
+{
+    "extensions": [
+        {
+            "updated": "2013-01-20T00:00:00-00:00",
+            "name": "Neutron Service Type Management",
+            "links": [],
+            "namespace": "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+            "alias": "service-type",
+            "description": "API for retrieving service providers for Neutron advanced services"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractExtensions(page)
+		if err != nil {
+			t.Errorf("Failed to extract extensions: %v", err)
+		}
+
+		expected := []Extension{
+			Extension{
+				common.Extension{
+					Updated:     "2013-01-20T00:00:00-00:00",
+					Name:        "Neutron Service Type Management",
+					Links:       []interface{}{},
+					Namespace:   "http://docs.openstack.org/ext/neutron/service-type/api/v1.0",
+					Alias:       "service-type",
+					Description: "API for retrieving service providers for Neutron advanced services",
+				},
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/extensions/agent", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "extension": {
+        "updated": "2013-02-03T10:00:00-00:00",
+        "name": "agent",
+        "links": [],
+        "namespace": "http://docs.openstack.org/ext/agent/api/v2.0",
+        "alias": "agent",
+        "description": "The agent management extension."
+    }
+}
+    `)
+	})
+
+	ext, err := Get(fake.ServiceClient(), "agent").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, ext.Updated, "2013-02-03T10:00:00-00:00")
+	th.AssertEquals(t, ext.Name, "agent")
+	th.AssertEquals(t, ext.Namespace, "http://docs.openstack.org/ext/agent/api/v2.0")
+	th.AssertEquals(t, ext.Alias, "agent")
+	th.AssertEquals(t, ext.Description, "The agent management extension.")
+}
diff --git a/openstack/networking/v2/extensions/external/doc.go b/openstack/networking/v2/extensions/external/doc.go
new file mode 100755
index 0000000..dad3a84
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/doc.go
@@ -0,0 +1,3 @@
+// Package external provides information and interaction with the external
+// extension for the OpenStack Networking service.
+package external
diff --git a/openstack/networking/v2/extensions/external/requests.go b/openstack/networking/v2/extensions/external/requests.go
new file mode 100644
index 0000000..1ee39d2
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/requests.go
@@ -0,0 +1,32 @@
+package external
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+)
+
+// CreateOpts is the structure used when creating new external network
+// resources. It embeds networks.CreateOpts and so inherits all of its required
+// and optional fields, with the addition of the External field.
+type CreateOpts struct {
+	networks.CreateOpts
+	External *bool `json:"router:external,omitempty"`
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// UpdateOpts is the structure used when updating existing external network
+// resources. It embeds networks.UpdateOpts and so inherits all of its required
+// and optional fields, with the addition of the External field.
+type UpdateOpts struct {
+	networks.UpdateOpts
+	External *bool `json:"router:external,omitempty"`
+}
+
+// ToNetworkUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "network")
+}
diff --git a/openstack/networking/v2/extensions/external/results.go b/openstack/networking/v2/extensions/external/results.go
new file mode 100644
index 0000000..7e10c6d
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/results.go
@@ -0,0 +1,76 @@
+package external
+
+import (
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// NetworkExternal represents a decorated form of a Network with based on the
+// "external-net" extension.
+type NetworkExternal struct {
+	// UUID for the network
+	ID string `json:"id"`
+
+	// Human-readable name for the network. Might not be unique.
+	Name string `json:"name"`
+
+	// The administrative state of network. If false (down), the network does not forward packets.
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `json:"status"`
+
+	// Subnets associated with this network.
+	Subnets []string `json:"subnets"`
+
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// Specifies whether the network resource can be accessed by any tenant or not.
+	Shared bool `json:"shared"`
+
+	// Specifies whether the network is an external network or not.
+	External bool `json:"router:external"`
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExternal, error) {
+	var s struct {
+		Network *NetworkExternal `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExternal, error) {
+	var s struct {
+		Network *NetworkExternal `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExternal, error) {
+	var s struct {
+		Network *NetworkExternal `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractList accepts a Page struct, specifically a NetworkPage struct, and
+// extracts the elements into a slice of NetworkExternal structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractList(r pagination.Page) ([]NetworkExternal, error) {
+	var s struct {
+		Networks []NetworkExternal `json:"networks" json:"networks"`
+	}
+	err := (r.(networks.NetworkPage)).ExtractInto(&s)
+	return s.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/external/results_test.go b/openstack/networking/v2/extensions/external/results_test.go
new file mode 100644
index 0000000..c5d72e9
--- /dev/null
+++ b/openstack/networking/v2/extensions/external/results_test.go
@@ -0,0 +1,255 @@
+package external
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "networks": [
+        {
+            "admin_state_up": true,
+            "id": "0f38d5ad-10a6-428f-a5fc-825cfe0f1970",
+            "name": "net1",
+            "router:external": false,
+            "shared": false,
+            "status": "ACTIVE",
+            "subnets": [
+                "25778974-48a8-46e7-8998-9dc8c70d2f06"
+            ],
+            "tenant_id": "b575417a6c444a6eb5cc3a58eb4f714a"
+        },
+        {
+            "admin_state_up": true,
+            "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+            "name": "ext_net",
+            "router:external": true,
+            "shared": false,
+            "status": "ACTIVE",
+            "subnets": [
+                "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+            ],
+            "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	networks.List(fake.ServiceClient(), networks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractList(page)
+		if err != nil {
+			t.Errorf("Failed to extract networks: %v", err)
+			return false, err
+		}
+
+		expected := []NetworkExternal{
+			NetworkExternal{
+				Status:       "ACTIVE",
+				Subnets:      []string{"25778974-48a8-46e7-8998-9dc8c70d2f06"},
+				Name:         "net1",
+				AdminStateUp: true,
+				TenantID:     "b575417a6c444a6eb5cc3a58eb4f714a",
+				Shared:       false,
+				ID:           "0f38d5ad-10a6-428f-a5fc-825cfe0f1970",
+				External:     false,
+			},
+			NetworkExternal{
+				Status:       "ACTIVE",
+				Subnets:      []string{"2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"},
+				Name:         "ext_net",
+				AdminStateUp: true,
+				TenantID:     "5eb8995cf717462c9df8d1edfa498010",
+				Shared:       false,
+				ID:           "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+				External:     true,
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "admin_state_up": true,
+        "id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+        "name": "ext_net",
+        "router:external": true,
+        "shared": false,
+        "status": "ACTIVE",
+        "subnets": [
+            "2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+        ],
+        "tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+    }
+}
+			`)
+	})
+
+	res := networks.Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	n, err := ExtractGet(res)
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, true, n.External)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "network": {
+        "admin_state_up": true,
+        "name": "ext_net",
+        "router:external": true
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+	"network": {
+			"admin_state_up": true,
+			"id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+			"name": "ext_net",
+			"router:external": true,
+			"shared": false,
+			"status": "ACTIVE",
+			"subnets": [
+					"2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+			],
+			"tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+	}
+}
+		`)
+	})
+
+	options := CreateOpts{networks.CreateOpts{Name: "ext_net", AdminStateUp: gophercloud.Enabled}, gophercloud.Enabled}
+	res := networks.Create(fake.ServiceClient(), options)
+
+	n, err := ExtractCreate(res)
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, true, n.External)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"network": {
+				"router:external": true,
+				"name": "new_name"
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"network": {
+			"admin_state_up": true,
+			"id": "8d05a1b1-297a-46ca-8974-17debf51ca3c",
+			"name": "new_name",
+			"router:external": true,
+			"shared": false,
+			"status": "ACTIVE",
+			"subnets": [
+					"2f1fb918-9b0e-4bf9-9a50-6cebbb4db2c5"
+			],
+			"tenant_id": "5eb8995cf717462c9df8d1edfa498010"
+	}
+}
+		`)
+	})
+
+	options := UpdateOpts{networks.UpdateOpts{Name: "new_name"}, gophercloud.Enabled}
+	res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+	n, err := ExtractUpdate(res)
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, true, n.External)
+}
+
+func TestExtractFnsReturnsErrWhenResultContainsErr(t *testing.T) {
+	gr := networks.GetResult{}
+	gr.Err = errors.New("")
+
+	if _, err := ExtractGet(gr); err == nil {
+		t.Fatalf("Expected error, got one")
+	}
+
+	ur := networks.UpdateResult{}
+	ur.Err = errors.New("")
+
+	if _, err := ExtractUpdate(ur); err == nil {
+		t.Fatalf("Expected error, got one")
+	}
+
+	cr := networks.CreateResult{}
+	cr.Err = errors.New("")
+
+	if _, err := ExtractCreate(cr); err == nil {
+		t.Fatalf("Expected error, got one")
+	}
+}
diff --git a/openstack/networking/v2/extensions/fwaas/doc.go b/openstack/networking/v2/extensions/fwaas/doc.go
new file mode 100644
index 0000000..3ec450a
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/doc.go
@@ -0,0 +1,3 @@
+// Package fwaas provides information and interaction with the Firewall
+// as a Service extension for the OpenStack Networking service.
+package fwaas
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/errors.go b/openstack/networking/v2/extensions/fwaas/firewalls/errors.go
new file mode 100644
index 0000000..dd92bb2
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/errors.go
@@ -0,0 +1,11 @@
+package firewalls
+
+import "fmt"
+
+func err(str string) error {
+	return fmt.Errorf("%s", str)
+}
+
+var (
+	errPolicyRequired = err("A policy ID is required")
+)
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/requests.go b/openstack/networking/v2/extensions/fwaas/firewalls/requests.go
new file mode 100644
index 0000000..21ceb4e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/requests.go
@@ -0,0 +1,140 @@
+package firewalls
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToFirewallListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the firewall attributes you want to see returned. SortKey allows you to sort
+// by a particular firewall attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	TenantID     string `q:"tenant_id"`
+	Name         string `q:"name"`
+	Description  string `q:"description"`
+	AdminStateUp bool   `q:"admin_state_up"`
+	Shared       bool   `q:"shared"`
+	PolicyID     string `q:"firewall_policy_id"`
+	ID           string `q:"id"`
+	Limit        int    `q:"limit"`
+	Marker       string `q:"marker"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// ToFirewallListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToFirewallListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewalls. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewalls that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := rootURL(c)
+	if opts != nil {
+		query, err := opts.ToFirewallListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return FirewallPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToFirewallCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall.
+type CreateOpts struct {
+	PolicyID string `json:"firewall_policy_id" required:"true"`
+	// Only required if the caller has an admin role and wants to create a firewall
+	// for another tenant.
+	TenantID     string `json:"tenant_id,omitempty"`
+	Name         string `json:"name,omitempty"`
+	Description  string `json:"description,omitempty"`
+	AdminStateUp *bool  `json:"admin_state_up,omitempty"`
+	Shared       *bool  `json:"shared,omitempty"`
+}
+
+// ToFirewallCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToFirewallCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToFirewallCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular firewall based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+	ToFirewallUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall.
+type UpdateOpts struct {
+	PolicyID     string `json:"firewall_policy_id" required:"true"`
+	Name         string `json:"name,omitempty"`
+	Description  string `json:"description,omitempty"`
+	AdminStateUp *bool  `json:"admin_state_up,omitempty"`
+	Shared       *bool  `json:"shared,omitempty"`
+}
+
+// ToFirewallUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToFirewallUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall")
+}
+
+// Update allows firewalls to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToFirewallUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular firewall based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go b/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go
new file mode 100644
index 0000000..29a7832
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/requests_test.go
@@ -0,0 +1,247 @@
+package firewalls
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewalls", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewalls", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "firewalls":[
+        {
+           "status": "ACTIVE",
+           "name": "fw1",
+           "admin_state_up": false,
+           "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+           "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+           "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+           "description": "OpenStack firewall 1"
+        },
+        {
+           "status": "PENDING_UPDATE",
+           "name": "fw2",
+           "admin_state_up": true,
+           "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+           "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e299",
+           "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f99",
+           "description": "OpenStack firewall 2"
+        }
+   ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractFirewalls(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+
+		expected := []Firewall{
+			Firewall{
+				Status:       "ACTIVE",
+				Name:         "fw1",
+				AdminStateUp: false,
+				TenantID:     "b4eedccc6fb74fa8a7ad6b08382b852b",
+				PolicyID:     "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+				ID:           "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+				Description:  "OpenStack firewall 1",
+			},
+			Firewall{
+				Status:       "PENDING_UPDATE",
+				Name:         "fw2",
+				AdminStateUp: true,
+				TenantID:     "b4eedccc6fb74fa8a7ad6b08382b852b",
+				PolicyID:     "34be8c83-4d42-4dca-a74e-b77fffb8e299",
+				ID:           "fb5b5315-64f6-4ea3-8e58-981cc37c6f99",
+				Description:  "OpenStack firewall 2",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewalls", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "firewall":{
+        "name": "fw",
+        "description": "OpenStack firewall",
+        "admin_state_up": true,
+        "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+        "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "firewall":{
+        "status": "PENDING_CREATE",
+        "name": "fw",
+        "description": "OpenStack firewall",
+        "admin_state_up": true,
+        "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+        "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c"
+    }
+}
+    `)
+	})
+
+	options := CreateOpts{
+		TenantID:     "b4eedccc6fb74fa8a7ad6b08382b852b",
+		Name:         "fw",
+		Description:  "OpenStack firewall",
+		AdminStateUp: gophercloud.Enabled,
+		PolicyID:     "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+	}
+	_, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewalls/fb5b5315-64f6-4ea3-8e58-981cc37c6f61", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall": {
+        "status": "ACTIVE",
+        "name": "fw",
+        "admin_state_up": true,
+        "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+        "firewall_policy_id": "34be8c83-4d42-4dca-a74e-b77fffb8e28a",
+        "id": "fb5b5315-64f6-4ea3-8e58-981cc37c6f61",
+        "description": "OpenStack firewall"
+    }
+}
+        `)
+	})
+
+	fw, err := Get(fake.ServiceClient(), "fb5b5315-64f6-4ea3-8e58-981cc37c6f61").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "ACTIVE", fw.Status)
+	th.AssertEquals(t, "fw", fw.Name)
+	th.AssertEquals(t, "OpenStack firewall", fw.Description)
+	th.AssertEquals(t, true, fw.AdminStateUp)
+	th.AssertEquals(t, "34be8c83-4d42-4dca-a74e-b77fffb8e28a", fw.PolicyID)
+	th.AssertEquals(t, "fb5b5315-64f6-4ea3-8e58-981cc37c6f61", fw.ID)
+	th.AssertEquals(t, "b4eedccc6fb74fa8a7ad6b08382b852b", fw.TenantID)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewalls/ea5b5315-64f6-4ea3-8e58-981cc37c6576", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "firewall":{
+        "name": "fw",
+        "description": "updated fw",
+        "admin_state_up":false,
+        "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall": {
+        "status": "ACTIVE",
+        "name": "fw",
+        "admin_state_up": false,
+        "tenant_id": "b4eedccc6fb74fa8a7ad6b08382b852b",
+        "firewall_policy_id": "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+        "id": "ea5b5315-64f6-4ea3-8e58-981cc37c6576",
+        "description": "OpenStack firewall"
+    }
+}
+    `)
+	})
+
+	options := UpdateOpts{
+		Name:         "fw",
+		Description:  "updated fw",
+		AdminStateUp: gophercloud.Disabled,
+		PolicyID:     "19ab8c87-4a32-4e6a-a74e-b77fffb89a0c",
+	}
+
+	_, err := Update(fake.ServiceClient(), "ea5b5315-64f6-4ea3-8e58-981cc37c6576", options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewalls/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/results.go b/openstack/networking/v2/extensions/fwaas/firewalls/results.go
new file mode 100644
index 0000000..ab6cf8e
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/results.go
@@ -0,0 +1,87 @@
+package firewalls
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Firewall is an OpenStack firewall.
+type Firewall struct {
+	ID           string `json:"id"`
+	Name         string `json:"name"`
+	Description  string `json:"description"`
+	AdminStateUp bool   `json:"admin_state_up"`
+	Status       string `json:"status"`
+	PolicyID     string `json:"firewall_policy_id"`
+	TenantID     string `json:"tenant_id"`
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall.
+func (r commonResult) Extract() (*Firewall, error) {
+	var s struct {
+		Firewall *Firewall `json:"firewall"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Firewall, err
+}
+
+// FirewallPage is the page returned by a pager when traversing over a
+// collection of firewalls.
+type FirewallPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewalls has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r FirewallPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"firewalls_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a FirewallPage struct is empty.
+func (r FirewallPage) IsEmpty() (bool, error) {
+	is, err := ExtractFirewalls(r)
+	return len(is) == 0, err
+}
+
+// ExtractFirewalls accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractFirewalls(r pagination.Page) ([]Firewall, error) {
+	var s struct {
+		Firewalls []Firewall `json:"firewalls" json:"firewalls"`
+	}
+	err := (r.(FirewallPage)).ExtractInto(&s)
+	return s.Firewalls, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/firewalls/urls.go b/openstack/networking/v2/extensions/fwaas/firewalls/urls.go
new file mode 100644
index 0000000..807ea1a
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/firewalls/urls.go
@@ -0,0 +1,16 @@
+package firewalls
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "fw"
+	resourcePath = "firewalls"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/requests.go b/openstack/networking/v2/extensions/fwaas/policies/requests.go
new file mode 100644
index 0000000..437d124
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/requests.go
@@ -0,0 +1,173 @@
+package policies
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToPolicyListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the firewall policy attributes you want to see returned. SortKey allows you
+// to sort by a particular firewall policy attribute. SortDir sets the direction,
+// and is either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	TenantID    string `q:"tenant_id"`
+	Name        string `q:"name"`
+	Description string `q:"description"`
+	Shared      *bool  `q:"shared"`
+	Audited     *bool  `q:"audited"`
+	ID          string `q:"id"`
+	Limit       int    `q:"limit"`
+	Marker      string `q:"marker"`
+	SortKey     string `q:"sort_key"`
+	SortDir     string `q:"sort_dir"`
+}
+
+// ToPolicyListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPolicyListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewall policies. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewall policies that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := rootURL(c)
+	if opts != nil {
+		query, err := opts.ToPolicyListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return PolicyPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToFirewallPolicyCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall policy.
+type CreateOpts struct {
+	// Only required if the caller has an admin role and wants to create a firewall policy
+	// for another tenant.
+	TenantID    string   `json:"tenant_id,omitempty"`
+	Name        string   `json:"name,omitempty"`
+	Description string   `json:"description,omitempty"`
+	Shared      *bool    `json:"shared,omitempty"`
+	Audited     *bool    `json:"audited,omitempty"`
+	Rules       []string `json:"firewall_rules,omitempty"`
+}
+
+// ToFirewallPolicyCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToFirewallPolicyCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall_policy")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall policy
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToFirewallPolicyCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular firewall policy based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+	ToFirewallPolicyUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall policy.
+type UpdateOpts struct {
+	Name        string   `json:"name,omitempty"`
+	Description string   `json:"description,omitempty"`
+	Shared      *bool    `json:"shared,omitempty"`
+	Audited     *bool    `json:"audited,omitempty"`
+	Rules       []string `json:"firewall_rules,omitempty"`
+}
+
+// ToFirewallPolicyUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToFirewallPolicyUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall_policy")
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToFirewallPolicyUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular firewall policy based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
+
+type InsertRuleOptsBuilder interface {
+	ToFirewallPolicyInsertRuleMap() (map[string]interface{}, error)
+}
+
+type InsertRuleOpts struct {
+	ID           string `json:"firewall_rule_id" required:"true"`
+	BeforeRuleID string `json:"insert_before,omitempty"`
+	AfterRuleID  string `json:"insert_after,omitempty"`
+}
+
+func (opts InsertRuleOpts) ToFirewallPolicyInsertRuleMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+func AddRule(c *gophercloud.ServiceClient, id string, opts InsertRuleOptsBuilder) (r InsertRuleResult) {
+	b, err := opts.ToFirewallPolicyInsertRuleMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(insertURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+func RemoveRule(c *gophercloud.ServiceClient, id, ruleID string) (r RemoveRuleResult) {
+	b := map[string]interface{}{"firewall_rule_id": ruleID}
+	_, r.Err = c.Put(removeURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/requests_test.go b/openstack/networking/v2/extensions/fwaas/policies/requests_test.go
new file mode 100644
index 0000000..23d6a66
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/requests_test.go
@@ -0,0 +1,280 @@
+package policies
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewall_policies", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_policies", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall_policies": [
+        {
+            "name": "policy1",
+            "firewall_rules": [
+                "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+                "c9e77ca0-1bc8-497d-904d-948107873dc6"
+            ],
+            "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+            "audited": true,
+			"shared": false,
+            "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+            "description": "Firewall policy 1"
+        },
+        {
+            "name": "policy2",
+            "firewall_rules": [
+                "03d2a6ad-633f-431a-8463-4370d06a22c8"
+            ],
+            "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+            "audited": false,
+			"shared": true,
+            "id": "c854fab5-bdaf-4a86-9359-78de93e5df01",
+            "description": "Firewall policy 2"
+        }
+    ]
+}
+        `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractPolicies(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+
+		expected := []Policy{
+			Policy{
+				Name: "policy1",
+				Rules: []string{
+					"75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+					"c9e77ca0-1bc8-497d-904d-948107873dc6",
+				},
+				TenantID:    "9145d91459d248b1b02fdaca97c6a75d",
+				Audited:     true,
+				Shared:      false,
+				ID:          "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+				Description: "Firewall policy 1",
+			},
+			Policy{
+				Name: "policy2",
+				Rules: []string{
+					"03d2a6ad-633f-431a-8463-4370d06a22c8",
+				},
+				TenantID:    "9145d91459d248b1b02fdaca97c6a75d",
+				Audited:     false,
+				Shared:      true,
+				ID:          "c854fab5-bdaf-4a86-9359-78de93e5df01",
+				Description: "Firewall policy 2",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_policies", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "firewall_policy":{
+        "name": "policy",
+        "firewall_rules": [
+            "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+            "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+        ],
+        "description": "Firewall policy",
+		"tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+		"audited": true,
+		"shared": false
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "firewall_policy":{
+        "name": "policy",
+        "firewall_rules": [
+            "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+            "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+        ],
+        "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+        "audited": false,
+        "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+        "description": "Firewall policy"
+    }
+}
+        `)
+	})
+
+	options := CreateOpts{
+		TenantID:    "9145d91459d248b1b02fdaca97c6a75d",
+		Name:        "policy",
+		Description: "Firewall policy",
+		Shared:      gophercloud.Disabled,
+		Audited:     gophercloud.Enabled,
+		Rules: []string{
+			"98a58c87-76be-ae7c-a74e-b77fffb88d95",
+			"11a58c87-76be-ae7c-a74e-b77fffb88a32",
+		},
+	}
+
+	_, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_policies/bcab5315-64f6-4ea3-8e58-981cc37c6f61", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall_policy":{
+        "name": "www",
+        "firewall_rules": [
+            "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+            "c9e77ca0-1bc8-497d-904d-948107873dc6",
+            "03d2a6ad-633f-431a-8463-4370d06a22c8"
+        ],
+        "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+        "audited": false,
+        "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+        "description": "Firewall policy web"
+    }
+}
+        `)
+	})
+
+	policy, err := Get(fake.ServiceClient(), "bcab5315-64f6-4ea3-8e58-981cc37c6f61").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "www", policy.Name)
+	th.AssertEquals(t, "f2b08c1e-aa81-4668-8ae1-1401bcb0576c", policy.ID)
+	th.AssertEquals(t, "Firewall policy web", policy.Description)
+	th.AssertEquals(t, 3, len(policy.Rules))
+	th.AssertEquals(t, "75452b36-268e-4e75-aaf4-f0e7ed50bc97", policy.Rules[0])
+	th.AssertEquals(t, "c9e77ca0-1bc8-497d-904d-948107873dc6", policy.Rules[1])
+	th.AssertEquals(t, "03d2a6ad-633f-431a-8463-4370d06a22c8", policy.Rules[2])
+	th.AssertEquals(t, "9145d91459d248b1b02fdaca97c6a75d", policy.TenantID)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_policies/f2b08c1e-aa81-4668-8ae1-1401bcb0576c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "firewall_policy":{
+        "name": "policy",
+        "firewall_rules": [
+            "98a58c87-76be-ae7c-a74e-b77fffb88d95",
+            "11a58c87-76be-ae7c-a74e-b77fffb88a32"
+        ],
+        "description": "Firewall policy"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall_policy":{
+        "name": "policy",
+        "firewall_rules": [
+            "75452b36-268e-4e75-aaf4-f0e7ed50bc97",
+            "c9e77ca0-1bc8-497d-904d-948107873dc6",
+            "03d2a6ad-633f-431a-8463-4370d06a22c8"
+        ],
+        "tenant_id": "9145d91459d248b1b02fdaca97c6a75d",
+        "audited": false,
+        "id": "f2b08c1e-aa81-4668-8ae1-1401bcb0576c",
+        "description": "Firewall policy"
+    }
+}
+    `)
+	})
+
+	options := UpdateOpts{
+		Name:        "policy",
+		Description: "Firewall policy",
+		Rules: []string{
+			"98a58c87-76be-ae7c-a74e-b77fffb88d95",
+			"11a58c87-76be-ae7c-a74e-b77fffb88a32",
+		},
+	}
+
+	_, err := Update(fake.ServiceClient(), "f2b08c1e-aa81-4668-8ae1-1401bcb0576c", options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_policies/4ec89077-d057-4a2b-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/results.go b/openstack/networking/v2/extensions/fwaas/policies/results.go
new file mode 100644
index 0000000..9c5b186
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/results.go
@@ -0,0 +1,97 @@
+package policies
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Policy is a firewall policy.
+type Policy struct {
+	ID          string   `json:"id"`
+	Name        string   `json:"name"`
+	Description string   `json:"description"`
+	TenantID    string   `json:"tenant_id"`
+	Audited     bool     `json:"audited"`
+	Shared      bool     `json:"shared"`
+	Rules       []string `json:"firewall_rules,omitempty"`
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall policy.
+func (r commonResult) Extract() (*Policy, error) {
+	var s struct {
+		Policy *Policy `json:"firewall_policy"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Policy, err
+}
+
+// PolicyPage is the page returned by a pager when traversing over a
+// collection of firewall policies.
+type PolicyPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewall policies has
+// reached the end of a page and the pager seeks to traverse over a new one.
+// In order to do this, it needs to construct the next page's URL.
+func (r PolicyPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"firewall_policies_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PolicyPage struct is empty.
+func (r PolicyPage) IsEmpty() (bool, error) {
+	is, err := ExtractPolicies(r)
+	return len(is) == 0, err
+}
+
+// ExtractPolicies accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPolicies(r pagination.Page) ([]Policy, error) {
+	var s struct {
+		Policies []Policy `json:"firewall_policies"`
+	}
+	err := (r.(PolicyPage)).ExtractInto(&s)
+	return s.Policies, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// InsertRuleResult represents the result of an InsertRule operation.
+type InsertRuleResult struct {
+	commonResult
+}
+
+// RemoveRuleResult represents the result of a RemoveRule operation.
+type RemoveRuleResult struct {
+	commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/policies/urls.go b/openstack/networking/v2/extensions/fwaas/policies/urls.go
new file mode 100644
index 0000000..c252b79
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/policies/urls.go
@@ -0,0 +1,26 @@
+package policies
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "fw"
+	resourcePath = "firewall_policies"
+	insertPath   = "insert_rule"
+	removePath   = "remove_rule"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
+
+func insertURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id, insertPath)
+}
+
+func removeURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id, removePath)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/errors.go b/openstack/networking/v2/extensions/fwaas/rules/errors.go
new file mode 100644
index 0000000..0b29d39
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/errors.go
@@ -0,0 +1,12 @@
+package rules
+
+import "fmt"
+
+func err(str string) error {
+	return fmt.Errorf("%s", str)
+}
+
+var (
+	errProtocolRequired = err("A protocol is required (tcp, udp, icmp or any)")
+	errActionRequired   = err("An action is required (allow or deny)")
+)
diff --git a/openstack/networking/v2/extensions/fwaas/rules/requests.go b/openstack/networking/v2/extensions/fwaas/rules/requests.go
new file mode 100644
index 0000000..6b0814c
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/requests.go
@@ -0,0 +1,160 @@
+package rules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToRuleListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the Firewall rule attributes you want to see returned. SortKey allows you to
+// sort by a particular firewall rule attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	TenantID             string `q:"tenant_id"`
+	Name                 string `q:"name"`
+	Description          string `q:"description"`
+	Protocol             string `q:"protocol"`
+	Action               string `q:"action"`
+	IPVersion            int    `q:"ip_version"`
+	SourceIPAddress      string `q:"source_ip_address"`
+	DestinationIPAddress string `q:"destination_ip_address"`
+	SourcePort           string `q:"source_port"`
+	DestinationPort      string `q:"destination_port"`
+	Enabled              bool   `q:"enabled"`
+	ID                   string `q:"id"`
+	Limit                int    `q:"limit"`
+	Marker               string `q:"marker"`
+	SortKey              string `q:"sort_key"`
+	SortDir              string `q:"sort_dir"`
+}
+
+// ToRuleListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToRuleListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	if err != nil {
+		return "", err
+	}
+	return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// firewall rules. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+//
+// Default policy settings return only those firewall rules that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := rootURL(c)
+
+	if opts != nil {
+		query, err := opts.ToRuleListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return RulePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToRuleCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new firewall rule.
+type CreateOpts struct {
+	Protocol             string                `json:"protocol" required:"true"`
+	Action               string                `json:"action" required:"true"`
+	TenantID             string                `json:"tenant_id,omitempty"`
+	Name                 string                `json:"name,omitempty"`
+	Description          string                `json:"description,omitempty"`
+	IPVersion            gophercloud.IPVersion `json:"ip_version,omitempty"`
+	SourceIPAddress      string                `json:"source_ip_address,omitempty"`
+	DestinationIPAddress string                `json:"destination_ip_address,omitempty"`
+	SourcePort           string                `json:"source_port,omitempty"`
+	DestinationPort      string                `json:"destination_port,omitempty"`
+	Shared               *bool                 `json:"shared,omitempty"`
+	Enabled              *bool                 `json:"enabled,omitempty"`
+}
+
+// ToRuleCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToRuleCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall_rule")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new firewall rule
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToRuleCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular firewall rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+	ToRuleUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a firewall rule.
+type UpdateOpts struct {
+	Protocol             *string                `json:"protocol,omitempty"`
+	Action               *string                `json:"action,omitempty"`
+	Name                 *string                `json:"name,omitempty"`
+	Description          *string                `json:"description,omitempty"`
+	IPVersion            *gophercloud.IPVersion `json:"ip_version,omitempty"`
+	SourceIPAddress      *string                `json:"source_ip_address,omitempty"`
+	DestinationIPAddress *string                `json:"destination_ip_address,omitempty"`
+	SourcePort           *string                `json:"source_port,omitempty"`
+	DestinationPort      *string                `json:"destination_port,omitempty"`
+	Shared               *bool                  `json:"shared,omitempty"`
+	Enabled              *bool                  `json:"enabled,omitempty"`
+}
+
+// ToRuleUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToRuleUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "firewall_rule")
+}
+
+// Update allows firewall policies to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToRuleUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular firewall rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/requests_test.go b/openstack/networking/v2/extensions/fwaas/rules/requests_test.go
new file mode 100644
index 0000000..4ca73e7
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/requests_test.go
@@ -0,0 +1,326 @@
+package rules
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/fw/firewall_rules", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_rules", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "firewall_rules": [
+        {
+            "protocol": "tcp",
+            "description": "ssh rule",
+            "source_port": null,
+            "source_ip_address": null,
+            "destination_ip_address": "192.168.1.0/24",
+            "firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+            "position": 2,
+            "destination_port": "22",
+            "id": "f03bd950-6c56-4f5e-a307-45967078f507",
+            "name": "ssh_form_any",
+            "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+            "enabled": true,
+            "action": "allow",
+            "ip_version": 4,
+            "shared": false
+        },
+        {
+            "protocol": "udp",
+            "description": "udp rule",
+            "source_port": null,
+            "source_ip_address": null,
+            "destination_ip_address": null,
+            "firewall_policy_id": "98d7fb51-698c-4123-87e8-f1eee6b5ab7e",
+            "position": 1,
+            "destination_port": null,
+            "id": "ab7bd950-6c56-4f5e-a307-45967078f890",
+            "name": "deny_all_udp",
+            "tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+            "enabled": true,
+            "action": "deny",
+            "ip_version": 4,
+            "shared": false
+        }
+    ]
+}
+        `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractRules(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+
+		expected := []Rule{
+			Rule{
+				Protocol:             "tcp",
+				Description:          "ssh rule",
+				SourcePort:           "",
+				SourceIPAddress:      "",
+				DestinationIPAddress: "192.168.1.0/24",
+				PolicyID:             "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+				Position:             2,
+				DestinationPort:      "22",
+				ID:                   "f03bd950-6c56-4f5e-a307-45967078f507",
+				Name:                 "ssh_form_any",
+				TenantID:             "80cf934d6ffb4ef5b244f1c512ad1e61",
+				Enabled:              true,
+				Action:               "allow",
+				IPVersion:            4,
+				Shared:               false,
+			},
+			Rule{
+				Protocol:             "udp",
+				Description:          "udp rule",
+				SourcePort:           "",
+				SourceIPAddress:      "",
+				DestinationIPAddress: "",
+				PolicyID:             "98d7fb51-698c-4123-87e8-f1eee6b5ab7e",
+				Position:             1,
+				DestinationPort:      "",
+				ID:                   "ab7bd950-6c56-4f5e-a307-45967078f890",
+				Name:                 "deny_all_udp",
+				TenantID:             "80cf934d6ffb4ef5b244f1c512ad1e61",
+				Enabled:              true,
+				Action:               "deny",
+				IPVersion:            4,
+				Shared:               false,
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_rules", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+	"firewall_rule": {
+		"protocol": "tcp",
+		"description": "ssh rule",
+		"destination_ip_address": "192.168.1.0/24",
+		"destination_port": "22",
+		"name": "ssh_form_any",
+		"action": "allow",
+		"tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61"
+	}
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+	"firewall_rule":{
+		"protocol": "tcp",
+		"description": "ssh rule",
+		"source_port": null,
+		"source_ip_address": null,
+		"destination_ip_address": "192.168.1.0/24",
+		"firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+		"position": 2,
+		"destination_port": "22",
+		"id": "f03bd950-6c56-4f5e-a307-45967078f507",
+		"name": "ssh_form_any",
+		"tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+		"enabled": true,
+		"action": "allow",
+		"ip_version": 4,
+		"shared": false
+	}
+}
+        `)
+	})
+
+	options := CreateOpts{
+		TenantID:             "80cf934d6ffb4ef5b244f1c512ad1e61",
+		Protocol:             "tcp",
+		Description:          "ssh rule",
+		DestinationIPAddress: "192.168.1.0/24",
+		DestinationPort:      "22",
+		Name:                 "ssh_form_any",
+		Action:               "allow",
+	}
+
+	_, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_rules/f03bd950-6c56-4f5e-a307-45967078f507", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"firewall_rule":{
+		"protocol": "tcp",
+		"description": "ssh rule",
+		"source_port": null,
+		"source_ip_address": null,
+		"destination_ip_address": "192.168.1.0/24",
+		"firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+		"position": 2,
+		"destination_port": "22",
+		"id": "f03bd950-6c56-4f5e-a307-45967078f507",
+		"name": "ssh_form_any",
+		"tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+		"enabled": true,
+		"action": "allow",
+		"ip_version": 4,
+		"shared": false
+	}
+}
+        `)
+	})
+
+	rule, err := Get(fake.ServiceClient(), "f03bd950-6c56-4f5e-a307-45967078f507").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "tcp", rule.Protocol)
+	th.AssertEquals(t, "ssh rule", rule.Description)
+	th.AssertEquals(t, "192.168.1.0/24", rule.DestinationIPAddress)
+	th.AssertEquals(t, "e2a5fb51-698c-4898-87e8-f1eee6b50919", rule.PolicyID)
+	th.AssertEquals(t, 2, rule.Position)
+	th.AssertEquals(t, "22", rule.DestinationPort)
+	th.AssertEquals(t, "f03bd950-6c56-4f5e-a307-45967078f507", rule.ID)
+	th.AssertEquals(t, "ssh_form_any", rule.Name)
+	th.AssertEquals(t, "80cf934d6ffb4ef5b244f1c512ad1e61", rule.TenantID)
+	th.AssertEquals(t, true, rule.Enabled)
+	th.AssertEquals(t, "allow", rule.Action)
+	th.AssertEquals(t, 4, rule.IPVersion)
+	th.AssertEquals(t, false, rule.Shared)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_rules/f03bd950-6c56-4f5e-a307-45967078f507", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+	"firewall_rule":{
+		"protocol": "tcp",
+		"description": "ssh rule",
+		"destination_ip_address": "192.168.1.0/24",
+		"destination_port": "22",
+		"name": "ssh_form_any",
+		"action": "allow",
+		"enabled": false
+	}
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"firewall_rule":{
+		"protocol": "tcp",
+		"description": "ssh rule",
+		"destination_ip_address": "192.168.1.0/24",
+		"firewall_policy_id": "e2a5fb51-698c-4898-87e8-f1eee6b50919",
+		"position": 2,
+		"destination_port": "22",
+		"id": "f03bd950-6c56-4f5e-a307-45967078f507",
+		"name": "ssh_form_any",
+		"tenant_id": "80cf934d6ffb4ef5b244f1c512ad1e61",
+		"enabled": false,
+		"action": "allow",
+		"ip_version": 4,
+		"shared": false
+	}
+}
+		`)
+	})
+
+	newProtocol := "tcp"
+	newDescription := "ssh rule"
+	newDestinationIP := "192.168.1.0/24"
+	newDestintionPort := "22"
+	newName := "ssh_form_any"
+	newAction := "allow"
+
+	options := UpdateOpts{
+		Protocol:             &newProtocol,
+		Description:          &newDescription,
+		DestinationIPAddress: &newDestinationIP,
+		DestinationPort:      &newDestintionPort,
+		Name:                 &newName,
+		Action:               &newAction,
+		Enabled:              gophercloud.Disabled,
+	}
+
+	_, err := Update(fake.ServiceClient(), "f03bd950-6c56-4f5e-a307-45967078f507", options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/fw/firewall_rules/4ec89077-d057-4a2b-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89077-d057-4a2b-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/results.go b/openstack/networking/v2/extensions/fwaas/rules/results.go
new file mode 100644
index 0000000..c44e5a9
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/results.go
@@ -0,0 +1,95 @@
+package rules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Rule represents a firewall rule
+type Rule struct {
+	ID                   string `json:"id"`
+	Name                 string `json:"name,omitempty"`
+	Description          string `json:"description,omitempty"`
+	Protocol             string `json:"protocol"`
+	Action               string `json:"action"`
+	IPVersion            int    `json:"ip_version,omitempty"`
+	SourceIPAddress      string `json:"source_ip_address,omitempty"`
+	DestinationIPAddress string `json:"destination_ip_address,omitempty"`
+	SourcePort           string `json:"source_port,omitempty"`
+	DestinationPort      string `json:"destination_port,omitempty"`
+	Shared               bool   `json:"shared,omitempty"`
+	Enabled              bool   `json:"enabled,omitempty"`
+	PolicyID             string `json:"firewall_policy_id"`
+	Position             int    `json:"position"`
+	TenantID             string `json:"tenant_id"`
+}
+
+// RulePage is the page returned by a pager when traversing over a
+// collection of firewall rules.
+type RulePage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of firewall rules has
+// reached the end of a page and the pager seeks to traverse over a new one.
+// In order to do this, it needs to construct the next page's URL.
+func (r RulePage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"firewall_rules_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a RulePage struct is empty.
+func (r RulePage) IsEmpty() (bool, error) {
+	is, err := ExtractRules(r)
+	return len(is) == 0, err
+}
+
+// ExtractRules accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRules(r pagination.Page) ([]Rule, error) {
+	var s struct {
+		Rules []Rule `json:"firewall_rules"`
+	}
+	err := (r.(RulePage)).ExtractInto(&s)
+	return s.Rules, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a firewall rule.
+func (r commonResult) Extract() (*Rule, error) {
+	var s struct {
+		Rule *Rule `json:"firewall_rule"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Rule, err
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
diff --git a/openstack/networking/v2/extensions/fwaas/rules/urls.go b/openstack/networking/v2/extensions/fwaas/rules/urls.go
new file mode 100644
index 0000000..79654be
--- /dev/null
+++ b/openstack/networking/v2/extensions/fwaas/rules/urls.go
@@ -0,0 +1,16 @@
+package rules
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "fw"
+	resourcePath = "firewall_rules"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/layer3/doc.go b/openstack/networking/v2/extensions/layer3/doc.go
new file mode 100644
index 0000000..d533458
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/doc.go
@@ -0,0 +1,5 @@
+// Package layer3 provides access to the Layer-3 networking extension for the
+// OpenStack Neutron service. This extension allows API users to route packets
+// between subnets, forward packets from internal networks to external ones,
+// and access instances from external networks through floating IPs.
+package layer3
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests.go b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
new file mode 100644
index 0000000..ed6b263
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
@@ -0,0 +1,145 @@
+package floatingips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	ID                string `q:"id"`
+	FloatingNetworkID string `q:"floating_network_id"`
+	PortID            string `q:"port_id"`
+	FixedIP           string `q:"fixed_ip_address"`
+	FloatingIP        string `q:"floating_ip_address"`
+	TenantID          string `q:"tenant_id"`
+	Limit             int    `q:"limit"`
+	Marker            string `q:"marker"`
+	SortKey           string `q:"sort_key"`
+	SortDir           string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// floating IP resources. It accepts a ListOpts struct, which allows you to
+// filter and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return FloatingIPPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder is the interface type must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+	ToFloatingIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new floating IP
+// resource. The only required fields are FloatingNetworkID and PortID which
+// refer to the external network and internal port respectively.
+type CreateOpts struct {
+	FloatingNetworkID string `json:"floating_network_id" required:"true"`
+	FloatingIP        string `json:"floating_ip_address,omitempty"`
+	PortID            string `json:"port_id,omitempty"`
+	FixedIP           string `json:"fixed_ip_address,omitempty"`
+	TenantID          string `json:"tenant_id,omitempty"`
+}
+
+// ToFloatingIPCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToFloatingIPCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "floatingip")
+}
+
+// Create accepts a CreateOpts struct and uses the values provided to create a
+// new floating IP resource. You can create floating IPs on external networks
+// only. If you provide a FloatingNetworkID which refers to a network that is
+// not external (i.e. its `router:external' attribute is False), the operation
+// will fail and return a 400 error.
+//
+// If you do not specify a FloatingIP address value, the operation will
+// automatically allocate an available address for the new resource. If you do
+// choose to specify one, it must fall within the subnet range for the external
+// network - otherwise the operation returns a 400 error. If the FloatingIP
+// address is already in use, the operation returns a 409 error code.
+//
+// You can associate the new resource with an internal port by using the PortID
+// field. If you specify a PortID that is not valid, the operation will fail and
+// return 404 error code.
+//
+// You must also configure an IP address for the port associated with the PortID
+// you have provided - this is what the FixedIP refers to: an IP fixed to a port.
+// Because a port might be associated with multiple IP addresses, you can use
+// the FixedIP field to associate a particular IP address rather than have the
+// API assume for you. If you specify an IP address that is not valid, the
+// operation will fail and return a 400 error code. If the PortID and FixedIP
+// are already associated with another resource, the operation will fail and
+// returns a 409 error code.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToFloatingIPCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular floating IP resource based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface type must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+	ToFloatingIPUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a floating IP resource. The
+// only value that can be updated is which internal port the floating IP is
+// linked to. To associate the floating IP with a new internal port, provide its
+// ID. To disassociate the floating IP from all ports, provide an empty string.
+type UpdateOpts struct {
+	PortID string `json:"port_id"`
+}
+
+// ToFloatingIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder
+// interface
+func (opts UpdateOpts) ToFloatingIPUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "floatingip")
+}
+
+// Update allows floating IP resources to be updated. Currently, the only way to
+// "update" a floating IP is to associate it with a new internal port, or
+// disassociated it from all ports. See UpdateOpts for instructions of how to
+// do this.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToFloatingIPUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular floating IP resource. Please
+// ensure this is what you want - you can also disassociate the IP from existing
+// internal ports.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go b/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go
new file mode 100644
index 0000000..3e9a91f
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests_test.go
@@ -0,0 +1,355 @@
+package floatingips
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "floatingips": [
+        {
+            "floating_network_id": "6d67c30a-ddb4-49a1-bec3-a65b286b4170",
+            "router_id": null,
+            "fixed_ip_address": null,
+            "floating_ip_address": "192.0.0.4",
+            "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+            "status": "DOWN",
+            "port_id": null,
+            "id": "2f95fd2b-9f6a-4e8e-9e9a-2cbe286cbf9e"
+        },
+        {
+            "floating_network_id": "90f742b1-6d17-487b-ba95-71881dbc0b64",
+            "router_id": "0a24cb83-faf5-4d7f-b723-3144ed8a2167",
+            "fixed_ip_address": "192.0.0.2",
+            "floating_ip_address": "10.0.0.3",
+            "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+            "status": "DOWN",
+            "port_id": "74a342ce-8e07-4e91-880c-9f834b68fa25",
+            "id": "ada25a95-f321-4f59-b0e0-f3a970dd3d63"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractFloatingIPs(page)
+		if err != nil {
+			t.Errorf("Failed to extract floating IPs: %v", err)
+			return false, err
+		}
+
+		expected := []FloatingIP{
+			FloatingIP{
+				FloatingNetworkID: "6d67c30a-ddb4-49a1-bec3-a65b286b4170",
+				FixedIP:           "",
+				FloatingIP:        "192.0.0.4",
+				TenantID:          "017d8de156df4177889f31a9bd6edc00",
+				Status:            "DOWN",
+				PortID:            "",
+				ID:                "2f95fd2b-9f6a-4e8e-9e9a-2cbe286cbf9e",
+			},
+			FloatingIP{
+				FloatingNetworkID: "90f742b1-6d17-487b-ba95-71881dbc0b64",
+				FixedIP:           "192.0.0.2",
+				FloatingIP:        "10.0.0.3",
+				TenantID:          "017d8de156df4177889f31a9bd6edc00",
+				Status:            "DOWN",
+				PortID:            "74a342ce-8e07-4e91-880c-9f834b68fa25",
+				ID:                "ada25a95-f321-4f59-b0e0-f3a970dd3d63",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestInvalidNextPageURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `{"floatingips": [{}], "floatingips_links": {}}`)
+	})
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		ExtractFloatingIPs(page)
+		return true, nil
+	})
+}
+
+func TestRequiredFieldsForCreate(t *testing.T) {
+	res1 := Create(fake.ServiceClient(), CreateOpts{FloatingNetworkID: ""})
+	if res1.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+
+	res2 := Create(fake.ServiceClient(), CreateOpts{FloatingNetworkID: "foo", PortID: ""})
+	if res2.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "floatingip": {
+        "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+        "port_id": "ce705c24-c1ef-408a-bda3-7bbd946164ab"
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "floatingip": {
+        "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+        "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+        "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+        "fixed_ip_address": "10.0.0.3",
+        "floating_ip_address": "",
+        "port_id": "ce705c24-c1ef-408a-bda3-7bbd946164ab",
+        "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+    }
+}
+		`)
+	})
+
+	options := CreateOpts{
+		FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+		PortID:            "ce705c24-c1ef-408a-bda3-7bbd946164ab",
+	}
+
+	ip, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+	th.AssertEquals(t, "4969c491a3c74ee4af974e6d800c62de", ip.TenantID)
+	th.AssertEquals(t, "376da547-b977-4cfe-9cba-275c80debf57", ip.FloatingNetworkID)
+	th.AssertEquals(t, "", ip.FloatingIP)
+	th.AssertEquals(t, "ce705c24-c1ef-408a-bda3-7bbd946164ab", ip.PortID)
+	th.AssertEquals(t, "10.0.0.3", ip.FixedIP)
+}
+
+func TestCreateEmptyPort(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+			{
+				"floatingip": {
+					"floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57"
+				}
+			}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+				{
+					"floatingip": {
+						"router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+						"tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+						"floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+						"fixed_ip_address": "10.0.0.3",
+						"floating_ip_address": "",
+						"id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+					}
+				}
+				`)
+	})
+
+	options := CreateOpts{
+		FloatingNetworkID: "376da547-b977-4cfe-9cba-275c80debf57",
+	}
+
+	ip, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+	th.AssertEquals(t, "4969c491a3c74ee4af974e6d800c62de", ip.TenantID)
+	th.AssertEquals(t, "376da547-b977-4cfe-9cba-275c80debf57", ip.FloatingNetworkID)
+	th.AssertEquals(t, "", ip.FloatingIP)
+	th.AssertEquals(t, "", ip.PortID)
+	th.AssertEquals(t, "10.0.0.3", ip.FixedIP)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "floatingip": {
+        "floating_network_id": "90f742b1-6d17-487b-ba95-71881dbc0b64",
+        "fixed_ip_address": "192.0.0.2",
+        "floating_ip_address": "10.0.0.3",
+        "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+        "status": "DOWN",
+        "port_id": "74a342ce-8e07-4e91-880c-9f834b68fa25",
+        "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+    }
+}
+      `)
+	})
+
+	ip, err := Get(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "90f742b1-6d17-487b-ba95-71881dbc0b64", ip.FloatingNetworkID)
+	th.AssertEquals(t, "10.0.0.3", ip.FloatingIP)
+	th.AssertEquals(t, "74a342ce-8e07-4e91-880c-9f834b68fa25", ip.PortID)
+	th.AssertEquals(t, "192.0.0.2", ip.FixedIP)
+	th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", ip.TenantID)
+	th.AssertEquals(t, "DOWN", ip.Status)
+	th.AssertEquals(t, "2f245a7b-796b-4f26-9cf9-9e82d248fda7", ip.ID)
+}
+
+func TestAssociate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+	"floatingip": {
+		"port_id": "423abc8d-2991-4a55-ba98-2aaea84cc72e"
+	}
+}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+	"floatingip": {
+			"router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+			"tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+			"floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+			"fixed_ip_address": null,
+			"floating_ip_address": "172.24.4.228",
+			"port_id": "423abc8d-2991-4a55-ba98-2aaea84cc72e",
+			"id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+	}
+}
+	`)
+	})
+
+	ip, err := Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", UpdateOpts{PortID: "423abc8d-2991-4a55-ba98-2aaea84cc72e"}).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, "423abc8d-2991-4a55-ba98-2aaea84cc72e", ip.PortID)
+}
+
+func TestDisassociate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "floatingip": {
+      "port_id": ""
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "floatingip": {
+        "router_id": "d23abc8d-2991-4a55-ba98-2aaea84cc72f",
+        "tenant_id": "4969c491a3c74ee4af974e6d800c62de",
+        "floating_network_id": "376da547-b977-4cfe-9cba-275c80debf57",
+        "fixed_ip_address": null,
+        "floating_ip_address": "172.24.4.228",
+        "port_id": null,
+        "id": "2f245a7b-796b-4f26-9cf9-9e82d248fda7"
+    }
+}
+    `)
+	})
+
+	ip, err := Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", UpdateOpts{}).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, "", ip.FixedIP)
+	th.AssertDeepEquals(t, "", ip.PortID)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/floatingips/2f245a7b-796b-4f26-9cf9-9e82d248fda7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/results.go b/openstack/networking/v2/extensions/layer3/floatingips/results.go
new file mode 100644
index 0000000..838ca2c
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/results.go
@@ -0,0 +1,107 @@
+package floatingips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// FloatingIP represents a floating IP resource. A floating IP is an external
+// IP address that is mapped to an internal port and, optionally, a specific
+// IP address on a private network. In other words, it enables access to an
+// instance on a private network from an external network. For this reason,
+// floating IPs can only be defined on networks where the `router:external'
+// attribute (provided by the external network extension) is set to True.
+type FloatingIP struct {
+	// Unique identifier for the floating IP instance.
+	ID string `json:"id"`
+
+	// UUID of the external network where the floating IP is to be created.
+	FloatingNetworkID string `json:"floating_network_id"`
+
+	// Address of the floating IP on the external network.
+	FloatingIP string `json:"floating_ip_address"`
+
+	// UUID of the port on an internal network that is associated with the floating IP.
+	PortID string `json:"port_id"`
+
+	// The specific IP address of the internal port which should be associated
+	// with the floating IP.
+	FixedIP string `json:"fixed_ip_address"`
+
+	// Owner of the floating IP. Only admin users can specify a tenant identifier
+	// other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// The condition of the API resource.
+	Status string `json:"status"`
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract a result and extracts a FloatingIP resource.
+func (r commonResult) Extract() (*FloatingIP, error) {
+	var s struct {
+		FloatingIP *FloatingIP `json:"floatingip"`
+	}
+	err := r.ExtractInto(&s)
+	return s.FloatingIP, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of an update operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// FloatingIPPage is the page returned by a pager when traversing over a
+// collection of floating IPs.
+type FloatingIPPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of floating IPs has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r FloatingIPPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"floatingips_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (r FloatingIPPage) IsEmpty() (bool, error) {
+	is, err := ExtractFloatingIPs(r)
+	return len(is) == 0, err
+}
+
+// ExtractFloatingIPs accepts a Page struct, specifically a FloatingIPPage struct,
+// and extracts the elements into a slice of FloatingIP structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractFloatingIPs(r pagination.Page) ([]FloatingIP, error) {
+	var s struct {
+		FloatingIPs []FloatingIP `json:"floatingips"`
+	}
+	err := (r.(FloatingIPPage)).ExtractInto(&s)
+	return s.FloatingIPs, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/urls.go b/openstack/networking/v2/extensions/layer3/floatingips/urls.go
new file mode 100644
index 0000000..1318a18
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/floatingips/urls.go
@@ -0,0 +1,13 @@
+package floatingips
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "floatingips"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/requests.go b/openstack/networking/v2/extensions/layer3/routers/requests.go
new file mode 100644
index 0000000..48c0a52
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/requests.go
@@ -0,0 +1,219 @@
+package routers
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	ID           string `q:"id"`
+	Name         string `q:"name"`
+	AdminStateUp *bool  `q:"admin_state_up"`
+	Distributed  *bool  `q:"distributed"`
+	Status       string `q:"status"`
+	TenantID     string `q:"tenant_id"`
+	Limit        int    `q:"limit"`
+	Marker       string `q:"marker"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return RouterPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+type CreateOptsBuilder interface {
+	ToRouterCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new router. There are
+// no required values.
+type CreateOpts struct {
+	Name         string       `json:"name,omitempty"`
+	AdminStateUp *bool        `json:"admin_state_up,omitempty"`
+	Distributed  *bool        `json:"distributed,omitempty"`
+	TenantID     string       `json:"tenant_id,omitempty"`
+	GatewayInfo  *GatewayInfo `json:"external_gateway_info,omitempty"`
+}
+
+func (opts CreateOpts) ToRouterCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "router")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// logical router. When it is created, the router does not have an internal
+// interface - it is not associated to any subnet.
+//
+// You can optionally specify an external gateway for a router using the
+// GatewayInfo struct. The external gateway for the router must be plugged into
+// an external network (it is external if its `router:external' field is set to
+// true).
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToRouterCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular router based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+type UpdateOptsBuilder interface {
+	ToRouterUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a router.
+type UpdateOpts struct {
+	Name         string       `json:"name,omitempty"`
+	AdminStateUp *bool        `json:"admin_state_up,omitempty"`
+	Distributed  *bool        `json:"distributed,omitempty"`
+	GatewayInfo  *GatewayInfo `json:"external_gateway_info,omitempty"`
+	Routes       []Route      `json:"routes"`
+}
+
+func (opts UpdateOpts) ToRouterUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "router")
+}
+
+// Update allows routers to be updated. You can update the name, administrative
+// state, and the external gateway. For more information about how to set the
+// external gateway for a router, see Create. This operation does not enable
+// the update of router interfaces. To do this, use the AddInterface and
+// RemoveInterface functions.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToRouterUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular router based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
+
+// AddInterfaceOptsBuilder is what types must satisfy to be used as AddInterface
+// options.
+type AddInterfaceOptsBuilder interface {
+	ToRouterAddInterfaceMap() (map[string]interface{}, error)
+}
+
+// AddInterfaceOpts allow you to work with operations that either add
+// an internal interface from a router.
+type AddInterfaceOpts struct {
+	SubnetID string `json:"subnet_id,omitempty" xor:"PortID"`
+	PortID   string `json:"port_id,omitempty" xor:"SubnetID"`
+}
+
+// ToRouterAddInterfaceMap allows InterfaceOpts to satisfy the InterfaceOptsBuilder
+// interface
+func (opts AddInterfaceOpts) ToRouterAddInterfaceMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// AddInterface attaches a subnet to an internal router interface. You must
+// specify either a SubnetID or PortID in the request body. If you specify both,
+// the operation will fail and an error will be returned.
+//
+// If you specify a SubnetID, the gateway IP address for that particular subnet
+// is used to create the router interface. Alternatively, if you specify a
+// PortID, the IP address associated with the port is used to create the router
+// interface.
+//
+// If you reference a port that is associated with multiple IP addresses, or
+// if the port is associated with zero IP addresses, the operation will fail and
+// a 400 Bad Request error will be returned.
+//
+// If you reference a port already in use, the operation will fail and a 409
+// Conflict error will be returned.
+//
+// The PortID that is returned after using Extract() on the result of this
+// operation can either be the same PortID passed in or, on the other hand, the
+// identifier of a new port created by this operation. After the operation
+// completes, the device ID of the port is set to the router ID, and the
+// device owner attribute is set to `network:router_interface'.
+func AddInterface(c *gophercloud.ServiceClient, id string, opts AddInterfaceOptsBuilder) (r InterfaceResult) {
+	b, err := opts.ToRouterAddInterfaceMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(addInterfaceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// RemoveInterfaceOptsBuilder is what types must satisfy to be used as RemoveInterface
+// options.
+type RemoveInterfaceOptsBuilder interface {
+	ToRouterRemoveInterfaceMap() (map[string]interface{}, error)
+}
+
+// RemoveInterfaceOpts allow you to work with operations that either add or remote
+// an internal interface from a router.
+type RemoveInterfaceOpts struct {
+	SubnetID string `json:"subnet_id,omitempty" or:"PortID"`
+	PortID   string `json:"port_id,omitempty" or:"SubnetID"`
+}
+
+// ToRouterRemoveInterfaceMap allows RemoveInterfaceOpts to satisfy the RemoveInterfaceOptsBuilder
+// interface
+func (opts RemoveInterfaceOpts) ToRouterRemoveInterfaceMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// RemoveInterface removes an internal router interface, which detaches a
+// subnet from the router. You must specify either a SubnetID or PortID, since
+// these values are used to identify the router interface to remove.
+//
+// Unlike AddInterface, you can also specify both a SubnetID and PortID. If you
+// choose to specify both, the subnet ID must correspond to the subnet ID of
+// the first IP address on the port specified by the port ID. Otherwise, the
+// operation will fail and return a 409 Conflict error.
+//
+// If the router, subnet or port which are referenced do not exist or are not
+// visible to you, the operation will fail and a 404 Not Found error will be
+// returned. After this operation completes, the port connecting the router
+// with the subnet is removed from the subnet for the network.
+func RemoveInterface(c *gophercloud.ServiceClient, id string, opts RemoveInterfaceOptsBuilder) (r InterfaceResult) {
+	b, err := opts.ToRouterRemoveInterfaceMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(removeInterfaceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/requests_test.go b/openstack/networking/v2/extensions/layer3/routers/requests_test.go
new file mode 100644
index 0000000..c6cd7b3
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/requests_test.go
@@ -0,0 +1,413 @@
+package routers
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/routers", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "routers": [
+        {
+            "status": "ACTIVE",
+            "external_gateway_info": null,
+            "name": "second_routers",
+            "admin_state_up": true,
+            "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+            "distributed": false,
+            "id": "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b"
+        },
+        {
+            "status": "ACTIVE",
+            "external_gateway_info": {
+                "network_id": "3c5bcddd-6af9-4e6b-9c3e-c153e521cab8"
+            },
+            "name": "router1",
+            "admin_state_up": true,
+            "tenant_id": "33a40233088643acb66ff6eb0ebea679",
+            "distributed": false,
+            "id": "a9254bdb-2613-4a13-ac4c-adc581fba50d"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractRouters(page)
+		if err != nil {
+			t.Errorf("Failed to extract routers: %v", err)
+			return false, err
+		}
+
+		expected := []Router{
+			Router{
+				Status:       "ACTIVE",
+				GatewayInfo:  GatewayInfo{NetworkID: ""},
+				AdminStateUp: true,
+				Distributed:  false,
+				Name:         "second_routers",
+				ID:           "7177abc4-5ae9-4bb7-b0d4-89e94a4abf3b",
+				TenantID:     "6b96ff0cb17a4b859e1e575d221683d3",
+			},
+			Router{
+				Status:       "ACTIVE",
+				GatewayInfo:  GatewayInfo{NetworkID: "3c5bcddd-6af9-4e6b-9c3e-c153e521cab8"},
+				AdminStateUp: true,
+				Distributed:  false,
+				Name:         "router1",
+				ID:           "a9254bdb-2613-4a13-ac4c-adc581fba50d",
+				TenantID:     "33a40233088643acb66ff6eb0ebea679",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "router":{
+      "name": "foo_router",
+      "admin_state_up": false,
+      "external_gateway_info":{
+         "network_id":"8ca37218-28ff-41cb-9b10-039601ea7e6b"
+      }
+   }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "router": {
+        "status": "ACTIVE",
+        "external_gateway_info": {
+            "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+        },
+        "name": "foo_router",
+        "admin_state_up": false,
+        "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+        "distributed": false,
+        "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e"
+    }
+}
+		`)
+	})
+
+	asu := false
+	gwi := GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+
+	options := CreateOpts{
+		Name:         "foo_router",
+		AdminStateUp: &asu,
+		GatewayInfo:  &gwi,
+	}
+	r, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "foo_router", r.Name)
+	th.AssertEquals(t, false, r.AdminStateUp)
+	th.AssertDeepEquals(t, GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}, r.GatewayInfo)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/a07eea83-7710-4860-931b-5fe220fae533", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "router": {
+        "status": "ACTIVE",
+        "external_gateway_info": {
+            "network_id": "85d76829-6415-48ff-9c63-5c5ca8c61ac6"
+        },
+        "routes": [
+            {
+                "nexthop": "10.1.0.10",
+                "destination": "40.0.1.0/24"
+            }
+        ],
+        "name": "router1",
+        "admin_state_up": true,
+        "tenant_id": "d6554fe62e2f41efbb6e026fad5c1542",
+        "distributed": false,
+        "id": "a07eea83-7710-4860-931b-5fe220fae533"
+    }
+}
+			`)
+	})
+
+	n, err := Get(fake.ServiceClient(), "a07eea83-7710-4860-931b-5fe220fae533").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.GatewayInfo, GatewayInfo{NetworkID: "85d76829-6415-48ff-9c63-5c5ca8c61ac6"})
+	th.AssertEquals(t, n.Name, "router1")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.TenantID, "d6554fe62e2f41efbb6e026fad5c1542")
+	th.AssertEquals(t, n.ID, "a07eea83-7710-4860-931b-5fe220fae533")
+	th.AssertDeepEquals(t, n.Routes, []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}})
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "router": {
+			"name": "new_name",
+        "external_gateway_info": {
+            "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+		},
+        "routes": [
+            {
+                "nexthop": "10.1.0.10",
+                "destination": "40.0.1.0/24"
+            }
+        ]
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "router": {
+        "status": "ACTIVE",
+        "external_gateway_info": {
+            "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+        },
+        "name": "new_name",
+        "admin_state_up": true,
+        "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+        "distributed": false,
+        "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+        "routes": [
+            {
+                "nexthop": "10.1.0.10",
+                "destination": "40.0.1.0/24"
+            }
+        ]
+    }
+}
+		`)
+	})
+
+	gwi := GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"}
+	r := []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}}
+	options := UpdateOpts{Name: "new_name", GatewayInfo: &gwi, Routes: r}
+
+	n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Name, "new_name")
+	th.AssertDeepEquals(t, n.GatewayInfo, GatewayInfo{NetworkID: "8ca37218-28ff-41cb-9b10-039601ea7e6b"})
+	th.AssertDeepEquals(t, n.Routes, []Route{Route{DestinationCIDR: "40.0.1.0/24", NextHop: "10.1.0.10"}})
+}
+
+func TestAllRoutesRemoved(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "router": {
+        "routes": []
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "router": {
+        "status": "ACTIVE",
+        "external_gateway_info": {
+            "network_id": "8ca37218-28ff-41cb-9b10-039601ea7e6b"
+        },
+        "name": "name",
+        "admin_state_up": true,
+        "tenant_id": "6b96ff0cb17a4b859e1e575d221683d3",
+        "distributed": false,
+        "id": "8604a0de-7f6b-409a-a47c-a1cc7bc77b2e",
+        "routes": []
+    }
+}
+		`)
+	})
+
+	r := []Route{}
+	options := UpdateOpts{Routes: r}
+
+	n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, n.Routes, []Route{})
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestAddInterface(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c/add_router_interface", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet_id": "a2f1f29d-571b-4533-907f-5803ab96ead1"
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "subnet_id": "0d32a837-8069-4ec3-84c4-3eef3e10b188",
+    "tenant_id": "017d8de156df4177889f31a9bd6edc00",
+    "port_id": "3f990102-4485-4df1-97a0-2c35bdb85b31",
+    "id": "9a83fa11-8da5-436e-9afe-3d3ac5ce7770"
+}
+`)
+	})
+
+	opts := AddInterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+	res, err := AddInterface(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "0d32a837-8069-4ec3-84c4-3eef3e10b188", res.SubnetID)
+	th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", res.TenantID)
+	th.AssertEquals(t, "3f990102-4485-4df1-97a0-2c35bdb85b31", res.PortID)
+	th.AssertEquals(t, "9a83fa11-8da5-436e-9afe-3d3ac5ce7770", res.ID)
+}
+
+func TestAddInterfaceRequiredOpts(t *testing.T) {
+	_, err := AddInterface(fake.ServiceClient(), "foo", AddInterfaceOpts{}).Extract()
+	if err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	_, err = AddInterface(fake.ServiceClient(), "foo", AddInterfaceOpts{SubnetID: "bar", PortID: "baz"}).Extract()
+	if err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestRemoveInterface(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/routers/4e8e5957-649f-477b-9e5b-f1f75b21c03c/remove_router_interface", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"subnet_id": "a2f1f29d-571b-4533-907f-5803ab96ead1"
+}
+	`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+		"subnet_id": "0d32a837-8069-4ec3-84c4-3eef3e10b188",
+		"tenant_id": "017d8de156df4177889f31a9bd6edc00",
+		"port_id": "3f990102-4485-4df1-97a0-2c35bdb85b31",
+		"id": "9a83fa11-8da5-436e-9afe-3d3ac5ce7770"
+}
+`)
+	})
+
+	opts := RemoveInterfaceOpts{SubnetID: "a2f1f29d-571b-4533-907f-5803ab96ead1"}
+	res, err := RemoveInterface(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "0d32a837-8069-4ec3-84c4-3eef3e10b188", res.SubnetID)
+	th.AssertEquals(t, "017d8de156df4177889f31a9bd6edc00", res.TenantID)
+	th.AssertEquals(t, "3f990102-4485-4df1-97a0-2c35bdb85b31", res.PortID)
+	th.AssertEquals(t, "9a83fa11-8da5-436e-9afe-3d3ac5ce7770", res.ID)
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/results.go b/openstack/networking/v2/extensions/layer3/routers/results.go
new file mode 100644
index 0000000..d849d45
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/results.go
@@ -0,0 +1,152 @@
+package routers
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// GatewayInfo represents the information of an external gateway for any
+// particular network router.
+type GatewayInfo struct {
+	NetworkID string `json:"network_id"`
+}
+
+// Route is a possible route in a router.
+type Route struct {
+	NextHop         string `json:"nexthop"`
+	DestinationCIDR string `json:"destination"`
+}
+
+// Router represents a Neutron router. A router is a logical entity that
+// forwards packets across internal subnets and NATs (network address
+// translation) them on external networks through an appropriate gateway.
+//
+// A router has an interface for each subnet with which it is associated. By
+// default, the IP address of such interface is the subnet's gateway IP. Also,
+// whenever a router is associated with a subnet, a port for that router
+// interface is added to the subnet's network.
+type Router struct {
+	// Indicates whether or not a router is currently operational.
+	Status string `json:"status"`
+
+	// Information on external gateway for the router.
+	GatewayInfo GatewayInfo `json:"external_gateway_info"`
+
+	// Administrative state of the router.
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Whether router is disitrubted or not..
+	Distributed bool `json:"distributed"`
+
+	// Human readable name for the router. Does not have to be unique.
+	Name string `json:"name"`
+
+	// Unique identifier for the router.
+	ID string `json:"id"`
+
+	// Owner of the router. Only admin users can specify a tenant identifier
+	// other than its own.
+	TenantID string `json:"tenant_id"`
+
+	Routes []Route `json:"routes"`
+}
+
+// RouterPage is the page returned by a pager when traversing over a
+// collection of routers.
+type RouterPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of routers has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r RouterPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"routers_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a RouterPage struct is empty.
+func (r RouterPage) IsEmpty() (bool, error) {
+	is, err := ExtractRouters(r)
+	return len(is) == 0, err
+}
+
+// ExtractRouters accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRouters(r pagination.Page) ([]Router, error) {
+	var s struct {
+		Routers []Router `json:"routers"`
+	}
+	err := (r.(RouterPage)).ExtractInto(&s)
+	return s.Routers, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Router, error) {
+	var s struct {
+		Router *Router `json:"router"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Router, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// InterfaceInfo represents information about a particular router interface. As
+// mentioned above, in order for a router to forward to a subnet, it needs an
+// interface.
+type InterfaceInfo struct {
+	// The ID of the subnet which this interface is associated with.
+	SubnetID string `json:"subnet_id"`
+
+	// The ID of the port that is a part of the subnet.
+	PortID string `json:"port_id"`
+
+	// The UUID of the interface.
+	ID string `json:"id"`
+
+	// Owner of the interface.
+	TenantID string `json:"tenant_id"`
+}
+
+// InterfaceResult represents the result of interface operations, such as
+// AddInterface() and RemoveInterface().
+type InterfaceResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts an information struct.
+func (r InterfaceResult) Extract() (*InterfaceInfo, error) {
+	var s InterfaceInfo
+	err := r.ExtractInto(&s)
+	return &s, err
+}
diff --git a/openstack/networking/v2/extensions/layer3/routers/urls.go b/openstack/networking/v2/extensions/layer3/routers/urls.go
new file mode 100644
index 0000000..f9e9da3
--- /dev/null
+++ b/openstack/networking/v2/extensions/layer3/routers/urls.go
@@ -0,0 +1,21 @@
+package routers
+
+import "github.com/gophercloud/gophercloud"
+
+const resourcePath = "routers"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id)
+}
+
+func addInterfaceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id, "add_router_interface")
+}
+
+func removeInterfaceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(resourcePath, id, "remove_router_interface")
+}
diff --git a/openstack/networking/v2/extensions/lbaas/doc.go b/openstack/networking/v2/extensions/lbaas/doc.go
new file mode 100644
index 0000000..bc1fc28
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/doc.go
@@ -0,0 +1,3 @@
+// Package lbaas provides information and interaction with the Load Balancer
+// as a Service extension for the OpenStack Networking service.
+package lbaas
diff --git a/openstack/networking/v2/extensions/lbaas/members/requests.go b/openstack/networking/v2/extensions/lbaas/members/requests.go
new file mode 100644
index 0000000..f74eb82
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/requests.go
@@ -0,0 +1,115 @@
+package members
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status       string `q:"status"`
+	Weight       int    `q:"weight"`
+	AdminStateUp *bool  `q:"admin_state_up"`
+	TenantID     string `q:"tenant_id"`
+	PoolID       string `q:"pool_id"`
+	Address      string `q:"address"`
+	ProtocolPort int    `q:"protocol_port"`
+	ID           string `q:"id"`
+	Limit        int    `q:"limit"`
+	Marker       string `q:"marker"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// pools. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those pools that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return MemberPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+type CreateOptsBuilder interface {
+	ToLBMemberCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new pool member.
+type CreateOpts struct {
+	// The IP address of the member.
+	Address string `json:"address" required:"true"`
+	// The port on which the application is hosted.
+	ProtocolPort int `json:"protocol_port" required:"true"`
+	// The pool to which this member will belong.
+	PoolID string `json:"pool_id" required:"true"`
+	// Only required if the caller has an admin role and wants to create a pool
+	// for another tenant.
+	TenantID string `json:"tenant_id,omitempty" required:"true"`
+}
+
+func (opts CreateOpts) ToLBMemberCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// Create accepts a CreateOpts struct and uses the values to create a new
+// load balancer pool member.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToLBMemberCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular pool member based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+type UpdateOptsBuilder interface {
+	ToLBMemberUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a pool member.
+type UpdateOpts struct {
+	// The administrative state of the member, which is up (true) or down (false).
+	AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+func (opts UpdateOpts) ToLBMemberUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "member")
+}
+
+// Update allows members to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToLBMemberUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201, 202},
+	})
+	return
+}
+
+// Delete will permanently delete a particular member based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/requests_test.go b/openstack/networking/v2/extensions/lbaas/members/requests_test.go
new file mode 100644
index 0000000..27593e7
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/requests_test.go
@@ -0,0 +1,244 @@
+package members
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/lb/members", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/members", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "members":[
+      {
+         "status":"ACTIVE",
+         "weight":1,
+         "admin_state_up":true,
+         "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+         "pool_id":"72741b06-df4d-4715-b142-276b6bce75ab",
+         "address":"10.0.0.4",
+         "protocol_port":80,
+         "id":"701b531b-111a-4f21-ad85-4795b7b12af6"
+      },
+      {
+         "status":"ACTIVE",
+         "weight":1,
+         "admin_state_up":true,
+         "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+         "pool_id":"72741b06-df4d-4715-b142-276b6bce75ab",
+         "address":"10.0.0.3",
+         "protocol_port":80,
+         "id":"beb53b4d-230b-4abd-8118-575b8fa006ef"
+      }
+   ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractMembers(page)
+		if err != nil {
+			t.Errorf("Failed to extract members: %v", err)
+			return false, err
+		}
+
+		expected := []Member{
+			Member{
+				Status:       "ACTIVE",
+				Weight:       1,
+				AdminStateUp: true,
+				TenantID:     "83657cfcdfe44cd5920adaf26c48ceea",
+				PoolID:       "72741b06-df4d-4715-b142-276b6bce75ab",
+				Address:      "10.0.0.4",
+				ProtocolPort: 80,
+				ID:           "701b531b-111a-4f21-ad85-4795b7b12af6",
+			},
+			Member{
+				Status:       "ACTIVE",
+				Weight:       1,
+				AdminStateUp: true,
+				TenantID:     "83657cfcdfe44cd5920adaf26c48ceea",
+				PoolID:       "72741b06-df4d-4715-b142-276b6bce75ab",
+				Address:      "10.0.0.3",
+				ProtocolPort: 80,
+				ID:           "beb53b4d-230b-4abd-8118-575b8fa006ef",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/members", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+  "member": {
+    "tenant_id": "453105b9-1754-413f-aab1-55f1af620750",
+		"pool_id": "foo",
+    "address": "192.0.2.14",
+    "protocol_port":8080
+  }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+  "member": {
+    "id": "975592ca-e308-48ad-8298-731935ee9f45",
+    "address": "192.0.2.14",
+    "protocol_port": 8080,
+    "tenant_id": "453105b9-1754-413f-aab1-55f1af620750",
+    "admin_state_up":true,
+    "weight": 1,
+    "status": "DOWN"
+  }
+}
+    `)
+	})
+
+	options := CreateOpts{
+		TenantID:     "453105b9-1754-413f-aab1-55f1af620750",
+		Address:      "192.0.2.14",
+		ProtocolPort: 8080,
+		PoolID:       "foo",
+	}
+	_, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/members/975592ca-e308-48ad-8298-731935ee9f45", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "member":{
+      "id":"975592ca-e308-48ad-8298-731935ee9f45",
+      "address":"192.0.2.14",
+      "protocol_port":8080,
+      "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+      "admin_state_up":true,
+      "weight":1,
+      "status":"DOWN"
+   }
+}
+      `)
+	})
+
+	m, err := Get(fake.ServiceClient(), "975592ca-e308-48ad-8298-731935ee9f45").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "975592ca-e308-48ad-8298-731935ee9f45", m.ID)
+	th.AssertEquals(t, "192.0.2.14", m.Address)
+	th.AssertEquals(t, 8080, m.ProtocolPort)
+	th.AssertEquals(t, "453105b9-1754-413f-aab1-55f1af620750", m.TenantID)
+	th.AssertEquals(t, true, m.AdminStateUp)
+	th.AssertEquals(t, 1, m.Weight)
+	th.AssertEquals(t, "DOWN", m.Status)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/members/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "member":{
+      "admin_state_up":false
+   }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "member":{
+      "status":"PENDING_UPDATE",
+      "protocol_port":8080,
+      "weight":1,
+      "admin_state_up":false,
+      "tenant_id":"4fd44f30292945e481c7b8a0c8908869",
+      "pool_id":"7803631d-f181-4500-b3a2-1b68ba2a75fd",
+      "address":"10.0.0.5",
+      "status_description":null,
+      "id":"48a471ea-64f1-4eb6-9be7-dae6bbe40a0f"
+   }
+}
+    `)
+	})
+
+	options := UpdateOpts{AdminStateUp: gophercloud.Disabled}
+
+	_, err := Update(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/members/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/results.go b/openstack/networking/v2/extensions/lbaas/members/results.go
new file mode 100644
index 0000000..933e1ae
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/results.go
@@ -0,0 +1,104 @@
+package members
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Member represents the application running on a backend server.
+type Member struct {
+	// The status of the member. Indicates whether the member is operational.
+	Status string
+
+	// Weight of member.
+	Weight int
+
+	// The administrative state of the member, which is up (true) or down (false).
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Owner of the member. Only an administrative user can specify a tenant ID
+	// other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// The pool to which the member belongs.
+	PoolID string `json:"pool_id"`
+
+	// The IP address of the member.
+	Address string
+
+	// The port on which the application is hosted.
+	ProtocolPort int `json:"protocol_port"`
+
+	// The unique ID for the member.
+	ID string
+}
+
+// MemberPage is the page returned by a pager when traversing over a
+// collection of pool members.
+type MemberPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of members has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r MemberPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"members_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a MemberPage struct is empty.
+func (r MemberPage) IsEmpty() (bool, error) {
+	is, err := ExtractMembers(r)
+	return len(is) == 0, err
+}
+
+// ExtractMembers accepts a Page struct, specifically a MemberPage struct,
+// and extracts the elements into a slice of Member structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractMembers(r pagination.Page) ([]Member, error) {
+	var s struct {
+		Members []Member `json:"members"`
+	}
+	err := (r.(MemberPage)).ExtractInto(&s)
+	return s.Members, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Member, error) {
+	var s struct {
+		Member *Member `json:"member"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Member, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/members/urls.go b/openstack/networking/v2/extensions/lbaas/members/urls.go
new file mode 100644
index 0000000..e2248f8
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/members/urls.go
@@ -0,0 +1,16 @@
+package members
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "lb"
+	resourcePath = "members"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/requests.go b/openstack/networking/v2/extensions/lbaas/monitors/requests.go
new file mode 100644
index 0000000..f1b964b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/requests.go
@@ -0,0 +1,210 @@
+package monitors
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	ID            string `q:"id"`
+	TenantID      string `q:"tenant_id"`
+	Type          string `q:"type"`
+	Delay         int    `q:"delay"`
+	Timeout       int    `q:"timeout"`
+	MaxRetries    int    `q:"max_retries"`
+	HTTPMethod    string `q:"http_method"`
+	URLPath       string `q:"url_path"`
+	ExpectedCodes string `q:"expected_codes"`
+	AdminStateUp  *bool  `q:"admin_state_up"`
+	Status        string `q:"status"`
+	Limit         int    `q:"limit"`
+	Marker        string `q:"marker"`
+	SortKey       string `q:"sort_key"`
+	SortDir       string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return MonitorPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// MonitorType is the type for all the types of LB monitors
+type MonitorType string
+
+// Constants that represent approved monitoring types.
+const (
+	TypePING  MonitorType = "PING"
+	TypeTCP   MonitorType = "TCP"
+	TypeHTTP  MonitorType = "HTTP"
+	TypeHTTPS MonitorType = "HTTPS"
+)
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+	ToLBMonitorCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new health monitor.
+type CreateOpts struct {
+	// Required. The type of probe, which is PING, TCP, HTTP, or HTTPS, that is
+	// sent by the load balancer to verify the member state.
+	Type MonitorType `json:"type" required:"true"`
+	// Required. The time, in seconds, between sending probes to members.
+	Delay int `json:"delay" required:"true"`
+	// Required. Maximum number of seconds for a monitor to wait for a ping reply
+	// before it times out. The value must be less than the delay value.
+	Timeout int `json:"timeout" required:"true"`
+	// Required. Number of permissible ping failures before changing the member's
+	// status to INACTIVE. Must be a number between 1 and 10.
+	MaxRetries int `json:"max_retries" required:"true"`
+	// Required for HTTP(S) types. URI path that will be accessed if monitor type
+	// is HTTP or HTTPS.
+	URLPath string `json:"url_path,omitempty"`
+	// Required for HTTP(S) types. The HTTP method used for requests by the
+	// monitor. If this attribute is not specified, it defaults to "GET".
+	HTTPMethod string `json:"http_method,omitempty"`
+	// Required for HTTP(S) types. Expected HTTP codes for a passing HTTP(S)
+	// monitor. You can either specify a single status like "200", or a range
+	// like "200-202".
+	ExpectedCodes string `json:"expected_codes,omitempty"`
+	// Required for admins. Indicates the owner of the VIP.
+	TenantID     string `json:"tenant_id,omitempty"`
+	AdminStateUp *bool  `json:"admin_state_up,omitempty"`
+}
+
+// ToLBMonitorCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToLBMonitorCreateMap() (map[string]interface{}, error) {
+	if opts.Type == TypeHTTP || opts.Type == TypeHTTPS {
+		if opts.URLPath == "" {
+			err := gophercloud.ErrMissingInput{}
+			err.Argument = "monitors.CreateOpts.URLPath"
+			return nil, err
+		}
+		if opts.ExpectedCodes == "" {
+			err := gophercloud.ErrMissingInput{}
+			err.Argument = "monitors.CreateOpts.ExpectedCodes"
+			return nil, err
+		}
+	}
+	if opts.Delay < opts.Timeout {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "monitors.CreateOpts.Delay/monitors.CreateOpts.Timeout"
+		err.Info = "Delay must be greater than or equal to timeout"
+		return nil, err
+	}
+	return gophercloud.BuildRequestBody(opts, "health_monitor")
+}
+
+// Create is an operation which provisions a new health monitor. There are
+// different types of monitor you can provision: PING, TCP or HTTP(S). Below
+// are examples of how to create each one.
+//
+// Here is an example config struct to use when creating a PING or TCP monitor:
+//
+// CreateOpts{Type: TypePING, Delay: 20, Timeout: 10, MaxRetries: 3}
+// CreateOpts{Type: TypeTCP, Delay: 20, Timeout: 10, MaxRetries: 3}
+//
+// Here is an example config struct to use when creating a HTTP(S) monitor:
+//
+// CreateOpts{Type: TypeHTTP, Delay: 20, Timeout: 10, MaxRetries: 3,
+//  HttpMethod: "HEAD", ExpectedCodes: "200"}
+//
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToLBMonitorCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular health monitor based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is what types must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+	ToLBMonitorUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains all the values needed to update an existing virtual IP.
+// Attributes not listed here but appear in CreateOpts are immutable and cannot
+// be updated.
+type UpdateOpts struct {
+	// The time, in seconds, between sending probes to members.
+	Delay int `json:"delay,omitempty"`
+	// Maximum number of seconds for a monitor to wait for a ping reply
+	// before it times out. The value must be less than the delay value.
+	Timeout int `json:"timeout,omitempty"`
+	// Number of permissible ping failures before changing the member's
+	// status to INACTIVE. Must be a number between 1 and 10.
+	MaxRetries int `json:"max_retries,omitempty"`
+	// URI path that will be accessed if monitor type
+	// is HTTP or HTTPS.
+	URLPath string `json:"url_path,omitempty"`
+	// The HTTP method used for requests by the
+	// monitor. If this attribute is not specified, it defaults to "GET".
+	HTTPMethod string `json:"http_method,omitempty"`
+	// Expected HTTP codes for a passing HTTP(S)
+	// monitor. You can either specify a single status like "200", or a range
+	// like "200-202".
+	ExpectedCodes string `json:"expected_codes,omitempty"`
+	AdminStateUp  *bool  `json:"admin_state_up,omitempty"`
+}
+
+// ToLBMonitorUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder
+// interface
+func (opts UpdateOpts) ToLBMonitorUpdateMap() (map[string]interface{}, error) {
+	if opts.Delay > 0 && opts.Timeout > 0 && opts.Delay < opts.Timeout {
+		err := gophercloud.ErrInvalidInput{}
+		err.Argument = "monitors.CreateOpts.Delay/monitors.CreateOpts.Timeout"
+		err.Value = fmt.Sprintf("%d/%d", opts.Delay, opts.Timeout)
+		err.Info = "Delay must be greater than or equal to timeout"
+		return nil, err
+	}
+	return gophercloud.BuildRequestBody(opts, "health_monitor")
+}
+
+// Update is an operation which modifies the attributes of the specified monitor.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToLBMonitorUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 202},
+	})
+	return
+}
+
+// Delete will permanently delete a particular monitor based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go b/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go
new file mode 100644
index 0000000..bd22ae1
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/requests_test.go
@@ -0,0 +1,315 @@
+package monitors
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	th.AssertEquals(t, th.Endpoint()+"v2.0/lb/health_monitors", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/health_monitors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "health_monitors":[
+      {
+         "admin_state_up":true,
+         "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+         "delay":10,
+         "max_retries":1,
+         "timeout":1,
+         "type":"PING",
+         "id":"466c8345-28d8-4f84-a246-e04380b0461d"
+      },
+      {
+         "admin_state_up":true,
+         "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+         "delay":5,
+         "expected_codes":"200",
+         "max_retries":2,
+         "http_method":"GET",
+         "timeout":2,
+         "url_path":"/",
+         "type":"HTTP",
+         "id":"5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+      }
+   ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractMonitors(page)
+		if err != nil {
+			t.Errorf("Failed to extract monitors: %v", err)
+			return false, err
+		}
+
+		expected := []Monitor{
+			Monitor{
+				AdminStateUp: true,
+				TenantID:     "83657cfcdfe44cd5920adaf26c48ceea",
+				Delay:        10,
+				MaxRetries:   1,
+				Timeout:      1,
+				Type:         "PING",
+				ID:           "466c8345-28d8-4f84-a246-e04380b0461d",
+			},
+			Monitor{
+				AdminStateUp:  true,
+				TenantID:      "83657cfcdfe44cd5920adaf26c48ceea",
+				Delay:         5,
+				ExpectedCodes: "200",
+				MaxRetries:    2,
+				Timeout:       2,
+				URLPath:       "/",
+				Type:          "HTTP",
+				HTTPMethod:    "GET",
+				ID:            "5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestDelayMustBeGreaterOrEqualThanTimeout(t *testing.T) {
+	_, err := Create(fake.ServiceClient(), CreateOpts{
+		Type:          "HTTP",
+		Delay:         1,
+		Timeout:       10,
+		MaxRetries:    5,
+		URLPath:       "/check",
+		ExpectedCodes: "200-299",
+	}).Extract()
+
+	if err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+
+	_, err = Update(fake.ServiceClient(), "453105b9-1754-413f-aab1-55f1af620750", UpdateOpts{
+		Delay:   1,
+		Timeout: 10,
+	}).Extract()
+
+	if err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/health_monitors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "health_monitor":{
+      "type":"HTTP",
+      "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+      "delay":20,
+      "timeout":10,
+      "max_retries":5,
+      "url_path":"/check",
+      "expected_codes":"200-299"
+   }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+   "health_monitor":{
+      "id":"f3eeab00-8367-4524-b662-55e64d4cacb5",
+      "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+      "type":"HTTP",
+      "delay":20,
+      "timeout":10,
+      "max_retries":5,
+      "http_method":"GET",
+      "url_path":"/check",
+      "expected_codes":"200-299",
+      "admin_state_up":true,
+      "status":"ACTIVE"
+   }
+}
+		`)
+	})
+
+	_, err := Create(fake.ServiceClient(), CreateOpts{
+		Type:          "HTTP",
+		TenantID:      "453105b9-1754-413f-aab1-55f1af620750",
+		Delay:         20,
+		Timeout:       10,
+		MaxRetries:    5,
+		URLPath:       "/check",
+		ExpectedCodes: "200-299",
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Type: TypeHTTP})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/health_monitors/f3eeab00-8367-4524-b662-55e64d4cacb5", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "health_monitor":{
+      "id":"f3eeab00-8367-4524-b662-55e64d4cacb5",
+      "tenant_id":"453105b9-1754-413f-aab1-55f1af620750",
+      "type":"HTTP",
+      "delay":20,
+      "timeout":10,
+      "max_retries":5,
+      "http_method":"GET",
+      "url_path":"/check",
+      "expected_codes":"200-299",
+      "admin_state_up":true,
+      "status":"ACTIVE"
+   }
+}
+			`)
+	})
+
+	hm, err := Get(fake.ServiceClient(), "f3eeab00-8367-4524-b662-55e64d4cacb5").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "f3eeab00-8367-4524-b662-55e64d4cacb5", hm.ID)
+	th.AssertEquals(t, "453105b9-1754-413f-aab1-55f1af620750", hm.TenantID)
+	th.AssertEquals(t, "HTTP", hm.Type)
+	th.AssertEquals(t, 20, hm.Delay)
+	th.AssertEquals(t, 10, hm.Timeout)
+	th.AssertEquals(t, 5, hm.MaxRetries)
+	th.AssertEquals(t, "GET", hm.HTTPMethod)
+	th.AssertEquals(t, "/check", hm.URLPath)
+	th.AssertEquals(t, "200-299", hm.ExpectedCodes)
+	th.AssertEquals(t, true, hm.AdminStateUp)
+	th.AssertEquals(t, "ACTIVE", hm.Status)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/health_monitors/b05e44b5-81f9-4551-b474-711a722698f7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "health_monitor":{
+      "delay": 30,
+      "timeout": 20,
+      "max_retries": 10,
+      "url_path": "/another_check",
+      "expected_codes": "301",
+			"admin_state_up": true
+   }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+
+		fmt.Fprintf(w, `
+{
+    "health_monitor": {
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "delay": 30,
+        "max_retries": 10,
+        "http_method": "GET",
+        "timeout": 20,
+        "pools": [
+            {
+                "status": "PENDING_CREATE",
+                "status_description": null,
+                "pool_id": "6e55751f-6ad4-4e53-b8d4-02e442cd21df"
+            }
+        ],
+        "type": "PING",
+        "id": "b05e44b5-81f9-4551-b474-711a722698f7"
+    }
+}
+		`)
+	})
+
+	_, err := Update(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7", UpdateOpts{
+		Delay:         30,
+		Timeout:       20,
+		MaxRetries:    10,
+		URLPath:       "/another_check",
+		ExpectedCodes: "301",
+		AdminStateUp:  gophercloud.Enabled,
+	}).Extract()
+
+	th.AssertNoErr(t, err)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/health_monitors/b05e44b5-81f9-4551-b474-711a722698f7", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "b05e44b5-81f9-4551-b474-711a722698f7")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/results.go b/openstack/networking/v2/extensions/lbaas/monitors/results.go
new file mode 100644
index 0000000..11ba7df
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/results.go
@@ -0,0 +1,133 @@
+package monitors
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Monitor represents a load balancer health monitor. A health monitor is used
+// to determine whether or not back-end members of the VIP's pool are usable
+// for processing a request. A pool can have several health monitors associated
+// with it. There are different types of health monitors supported:
+//
+// PING: used to ping the members using ICMP.
+// TCP: used to connect to the members using TCP.
+// HTTP: used to send an HTTP request to the member.
+// HTTPS: used to send a secure HTTP request to the member.
+//
+// When a pool has several monitors associated with it, each member of the pool
+// is monitored by all these monitors. If any monitor declares the member as
+// unhealthy, then the member status is changed to INACTIVE and the member
+// won't participate in its pool's load balancing. In other words, ALL monitors
+// must declare the member to be healthy for it to stay ACTIVE.
+type Monitor struct {
+	// The unique ID for the VIP.
+	ID string
+
+	// Owner of the VIP. Only an administrative user can specify a tenant ID
+	// other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// The type of probe sent by the load balancer to verify the member state,
+	// which is PING, TCP, HTTP, or HTTPS.
+	Type string
+
+	// The time, in seconds, between sending probes to members.
+	Delay int
+
+	// The maximum number of seconds for a monitor to wait for a connection to be
+	// established before it times out. This value must be less than the delay value.
+	Timeout int
+
+	// Number of allowed connection failures before changing the status of the
+	// member to INACTIVE. A valid value is from 1 to 10.
+	MaxRetries int `json:"max_retries"`
+
+	// The HTTP method that the monitor uses for requests.
+	HTTPMethod string `json:"http_method"`
+
+	// The HTTP path of the request sent by the monitor to test the health of a
+	// member. Must be a string beginning with a forward slash (/).
+	URLPath string `json:"url_path"`
+
+	// Expected HTTP codes for a passing HTTP(S) monitor.
+	ExpectedCodes string `json:"expected_codes"`
+
+	// The administrative state of the health monitor, which is up (true) or down (false).
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// The status of the health monitor. Indicates whether the health monitor is
+	// operational.
+	Status string
+}
+
+// MonitorPage is the page returned by a pager when traversing over a
+// collection of health monitors.
+type MonitorPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of monitors has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r MonitorPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"health_monitors_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (r MonitorPage) IsEmpty() (bool, error) {
+	is, err := ExtractMonitors(r)
+	return len(is) == 0, err
+}
+
+// ExtractMonitors accepts a Page struct, specifically a MonitorPage struct,
+// and extracts the elements into a slice of Monitor structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractMonitors(r pagination.Page) ([]Monitor, error) {
+	var s struct {
+		Monitors []Monitor `json:"health_monitors"`
+	}
+	err := (r.(MonitorPage)).ExtractInto(&s)
+	return s.Monitors, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a monitor.
+func (r commonResult) Extract() (*Monitor, error) {
+	var s struct {
+		Monitor *Monitor `json:"health_monitor"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Monitor, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/monitors/urls.go b/openstack/networking/v2/extensions/lbaas/monitors/urls.go
new file mode 100644
index 0000000..e9d90fc
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/monitors/urls.go
@@ -0,0 +1,16 @@
+package monitors
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "lb"
+	resourcePath = "health_monitors"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/requests.go b/openstack/networking/v2/extensions/lbaas/pools/requests.go
new file mode 100644
index 0000000..043945b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/requests.go
@@ -0,0 +1,167 @@
+package pools
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status       string `q:"status"`
+	LBMethod     string `q:"lb_method"`
+	Protocol     string `q:"protocol"`
+	SubnetID     string `q:"subnet_id"`
+	TenantID     string `q:"tenant_id"`
+	AdminStateUp *bool  `q:"admin_state_up"`
+	Name         string `q:"name"`
+	ID           string `q:"id"`
+	VIPID        string `q:"vip_id"`
+	Limit        int    `q:"limit"`
+	Marker       string `q:"marker"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// pools. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those pools that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return PoolPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// LBMethod is a type used for possible load balancing methods
+type LBMethod string
+
+// LBProtocol is a type used for possible load balancing protocols
+type LBProtocol string
+
+// Supported attributes for create/update operations.
+const (
+	LBMethodRoundRobin       LBMethod = "ROUND_ROBIN"
+	LBMethodLeastConnections LBMethod = "LEAST_CONNECTIONS"
+
+	ProtocolTCP   LBProtocol = "TCP"
+	ProtocolHTTP  LBProtocol = "HTTP"
+	ProtocolHTTPS LBProtocol = "HTTPS"
+)
+
+// CreateOptsBuilder is the interface types must satisfy to be used as options
+// for the Create function
+type CreateOptsBuilder interface {
+	ToLBPoolCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new pool.
+type CreateOpts struct {
+	// Name of the pool.
+	Name string `json:"name" required:"true"`
+	// The protocol used by the pool members, you can use either
+	// ProtocolTCP, ProtocolHTTP, or ProtocolHTTPS.
+	Protocol LBProtocol `json:"protocol" required:"true"`
+	// Only required if the caller has an admin role and wants to create a pool
+	// for another tenant.
+	TenantID string `json:"tenant_id,omitempty"`
+	// The network on which the members of the pool will be located. Only members
+	// that are on this network can be added to the pool.
+	SubnetID string `json:"subnet_id,omitempty"`
+	// The algorithm used to distribute load between the members of the pool. The
+	// current specification supports LBMethodRoundRobin and
+	// LBMethodLeastConnections as valid values for this attribute.
+	LBMethod LBMethod `json:"lb_method" required:"true"`
+}
+
+// ToLBPoolCreateMap allows CreateOpts to satisfy the CreateOptsBuilder interface
+func (opts CreateOpts) ToLBPoolCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Create accepts a CreateOptsBuilder and uses the values to create a new
+// load balancer pool.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToLBPoolCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular pool based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface types must satisfy to be used as options
+// for the Update function
+type UpdateOptsBuilder interface {
+	ToLBPoolUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the values used when updating a pool.
+type UpdateOpts struct {
+	// Name of the pool.
+	Name string `json:"name,omitempty"`
+	// The algorithm used to distribute load between the members of the pool. The
+	// current specification supports LBMethodRoundRobin and
+	// LBMethodLeastConnections as valid values for this attribute.
+	LBMethod LBMethod `json:"lb_method,omitempty"`
+}
+
+// ToLBPoolUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder interface
+func (opts UpdateOpts) ToLBPoolUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "pool")
+}
+
+// Update allows pools to be updated.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToLBPoolUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Delete will permanently delete a particular pool based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
+
+// AssociateMonitor will associate a health monitor with a particular pool.
+// Once associated, the health monitor will start monitoring the members of the
+// pool and will deactivate these members if they are deemed unhealthy. A
+// member can be deactivated (status set to INACTIVE) if any of health monitors
+// finds it unhealthy.
+func AssociateMonitor(c *gophercloud.ServiceClient, poolID, monitorID string) (r AssociateResult) {
+	b := map[string]interface{}{"health_monitor": map[string]string{"id": monitorID}}
+	_, r.Err = c.Post(associateURL(c, poolID), b, &r.Body, nil)
+	return
+}
+
+// DisassociateMonitor will disassociate a health monitor with a particular
+// pool. When dissociation is successful, the health monitor will no longer
+// check for the health of the members of the pool.
+func DisassociateMonitor(c *gophercloud.ServiceClient, poolID, monitorID string) (r AssociateResult) {
+	_, r.Err = c.Delete(disassociateURL(c, poolID, monitorID), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/requests_test.go b/openstack/networking/v2/extensions/lbaas/pools/requests_test.go
new file mode 100644
index 0000000..5ef0a3b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/requests_test.go
@@ -0,0 +1,318 @@
+package pools
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/lb/pools", rootURL(fake.ServiceClient()))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "pools":[
+      {
+         "status":"ACTIVE",
+         "lb_method":"ROUND_ROBIN",
+         "protocol":"HTTP",
+         "description":"",
+         "health_monitors":[
+            "466c8345-28d8-4f84-a246-e04380b0461d",
+            "5d4b5228-33b0-4e60-b225-9b727c1a20e7"
+         ],
+         "members":[
+            "701b531b-111a-4f21-ad85-4795b7b12af6",
+            "beb53b4d-230b-4abd-8118-575b8fa006ef"
+         ],
+         "status_description": null,
+         "id":"72741b06-df4d-4715-b142-276b6bce75ab",
+         "vip_id":"4ec89087-d057-4e2c-911f-60a3b47ee304",
+         "name":"app_pool",
+         "admin_state_up":true,
+         "subnet_id":"8032909d-47a1-4715-90af-5153ffe39861",
+         "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+         "health_monitors_status": [],
+         "provider": "haproxy"
+      }
+   ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractPools(page)
+		if err != nil {
+			t.Errorf("Failed to extract pools: %v", err)
+			return false, err
+		}
+
+		expected := []Pool{
+			Pool{
+				Status:      "ACTIVE",
+				LBMethod:    "ROUND_ROBIN",
+				Protocol:    "HTTP",
+				Description: "",
+				MonitorIDs: []string{
+					"466c8345-28d8-4f84-a246-e04380b0461d",
+					"5d4b5228-33b0-4e60-b225-9b727c1a20e7",
+				},
+				SubnetID:     "8032909d-47a1-4715-90af-5153ffe39861",
+				TenantID:     "83657cfcdfe44cd5920adaf26c48ceea",
+				AdminStateUp: true,
+				Name:         "app_pool",
+				MemberIDs: []string{
+					"701b531b-111a-4f21-ad85-4795b7b12af6",
+					"beb53b4d-230b-4abd-8118-575b8fa006ef",
+				},
+				ID:       "72741b06-df4d-4715-b142-276b6bce75ab",
+				VIPID:    "4ec89087-d057-4e2c-911f-60a3b47ee304",
+				Provider: "haproxy",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "pool": {
+        "lb_method": "ROUND_ROBIN",
+        "protocol": "HTTP",
+        "name": "Example pool",
+        "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+        "tenant_id": "2ffc6e22aae24e4795f87155d24c896f"
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "pool": {
+        "status": "PENDING_CREATE",
+        "lb_method": "ROUND_ROBIN",
+        "protocol": "HTTP",
+        "description": "",
+        "health_monitors": [],
+        "members": [],
+        "status_description": null,
+        "id": "69055154-f603-4a28-8951-7cc2d9e54a9a",
+        "vip_id": null,
+        "name": "Example pool",
+        "admin_state_up": true,
+        "subnet_id": "1981f108-3c48-48d2-b908-30f7d28532c9",
+        "tenant_id": "2ffc6e22aae24e4795f87155d24c896f",
+        "health_monitors_status": []
+    }
+}
+		`)
+	})
+
+	options := CreateOpts{
+		LBMethod: LBMethodRoundRobin,
+		Protocol: "HTTP",
+		Name:     "Example pool",
+		SubnetID: "1981f108-3c48-48d2-b908-30f7d28532c9",
+		TenantID: "2ffc6e22aae24e4795f87155d24c896f",
+	}
+	p, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "PENDING_CREATE", p.Status)
+	th.AssertEquals(t, "ROUND_ROBIN", p.LBMethod)
+	th.AssertEquals(t, "HTTP", p.Protocol)
+	th.AssertEquals(t, "", p.Description)
+	th.AssertDeepEquals(t, []string{}, p.MonitorIDs)
+	th.AssertDeepEquals(t, []string{}, p.MemberIDs)
+	th.AssertEquals(t, "69055154-f603-4a28-8951-7cc2d9e54a9a", p.ID)
+	th.AssertEquals(t, "Example pool", p.Name)
+	th.AssertEquals(t, "1981f108-3c48-48d2-b908-30f7d28532c9", p.SubnetID)
+	th.AssertEquals(t, "2ffc6e22aae24e4795f87155d24c896f", p.TenantID)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "pool":{
+      "id":"332abe93-f488-41ba-870b-2ac66be7f853",
+      "tenant_id":"19eaa775-cf5d-49bc-902e-2f85f668d995",
+      "name":"Example pool",
+      "description":"",
+      "protocol":"tcp",
+      "lb_algorithm":"ROUND_ROBIN",
+      "session_persistence":{
+      },
+      "healthmonitor_id":null,
+      "members":[
+      ],
+      "admin_state_up":true,
+      "status":"ACTIVE"
+   }
+}
+			`)
+	})
+
+	n, err := Get(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.ID, "332abe93-f488-41ba-870b-2ac66be7f853")
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "pool":{
+      "name":"SuperPool",
+      "lb_method": "LEAST_CONNECTIONS"
+   }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+   "pool":{
+      "status":"PENDING_UPDATE",
+      "lb_method":"LEAST_CONNECTIONS",
+      "protocol":"TCP",
+      "description":"",
+      "health_monitors":[
+
+      ],
+      "subnet_id":"8032909d-47a1-4715-90af-5153ffe39861",
+      "tenant_id":"83657cfcdfe44cd5920adaf26c48ceea",
+      "admin_state_up":true,
+      "name":"SuperPool",
+      "members":[
+
+      ],
+      "id":"61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+      "vip_id":null
+   }
+}
+		`)
+	})
+
+	options := UpdateOpts{Name: "SuperPool", LBMethod: LBMethodLeastConnections}
+
+	n, err := Update(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "SuperPool", n.Name)
+	th.AssertDeepEquals(t, "LEAST_CONNECTIONS", n.LBMethod)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853")
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestAssociateHealthMonitor(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853/health_monitors", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+   "health_monitor":{
+      "id":"b624decf-d5d3-4c66-9a3d-f047e7786181"
+   }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{}`)
+	})
+
+	_, err := AssociateMonitor(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", "b624decf-d5d3-4c66-9a3d-f047e7786181").Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestDisassociateHealthMonitor(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/pools/332abe93-f488-41ba-870b-2ac66be7f853/health_monitors/b624decf-d5d3-4c66-9a3d-f047e7786181", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := DisassociateMonitor(fake.ServiceClient(), "332abe93-f488-41ba-870b-2ac66be7f853", "b624decf-d5d3-4c66-9a3d-f047e7786181")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/results.go b/openstack/networking/v2/extensions/lbaas/pools/results.go
new file mode 100644
index 0000000..2ca1963
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/results.go
@@ -0,0 +1,131 @@
+package pools
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Pool represents a logical set of devices, such as web servers, that you
+// group together to receive and process traffic. The load balancing function
+// chooses a member of the pool according to the configured load balancing
+// method to handle the new requests or connections received on the VIP address.
+// There is only one pool per virtual IP.
+type Pool struct {
+	// The status of the pool. Indicates whether the pool is operational.
+	Status string
+
+	// The load-balancer algorithm, which is round-robin, least-connections, and
+	// so on. This value, which must be supported, is dependent on the provider.
+	// Round-robin must be supported.
+	LBMethod string `json:"lb_method"`
+
+	// The protocol of the pool, which is TCP, HTTP, or HTTPS.
+	Protocol string
+
+	// Description for the pool.
+	Description string
+
+	// The IDs of associated monitors which check the health of the pool members.
+	MonitorIDs []string `json:"health_monitors"`
+
+	// The network on which the members of the pool will be located. Only members
+	// that are on this network can be added to the pool.
+	SubnetID string `json:"subnet_id"`
+
+	// Owner of the pool. Only an administrative user can specify a tenant ID
+	// other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// The administrative state of the pool, which is up (true) or down (false).
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Pool name. Does not have to be unique.
+	Name string
+
+	// List of member IDs that belong to the pool.
+	MemberIDs []string `json:"members"`
+
+	// The unique ID for the pool.
+	ID string
+
+	// The ID of the virtual IP associated with this pool
+	VIPID string `json:"vip_id"`
+
+	// The provider
+	Provider string
+}
+
+// PoolPage is the page returned by a pager when traversing over a
+// collection of pools.
+type PoolPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of pools has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r PoolPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"pools_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PoolPage struct is empty.
+func (r PoolPage) IsEmpty() (bool, error) {
+	is, err := ExtractPools(r)
+	return len(is) == 0, err
+}
+
+// ExtractPools accepts a Page struct, specifically a RouterPage struct,
+// and extracts the elements into a slice of Router structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPools(r pagination.Page) ([]Pool, error) {
+	var s struct {
+		Pools []Pool `json:"pools"`
+	}
+	err := (r.(PoolPage)).ExtractInto(&s)
+	return s.Pools, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*Pool, error) {
+	var s struct {
+		Pool *Pool `json:"pool"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Pool, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// AssociateResult represents the result of an association operation.
+type AssociateResult struct {
+	commonResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/pools/urls.go b/openstack/networking/v2/extensions/lbaas/pools/urls.go
new file mode 100644
index 0000000..fe3601b
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/pools/urls.go
@@ -0,0 +1,25 @@
+package pools
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "lb"
+	resourcePath = "pools"
+	monitorPath  = "health_monitors"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
+
+func associateURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id, monitorPath)
+}
+
+func disassociateURL(c *gophercloud.ServiceClient, poolID, monitorID string) string {
+	return c.ServiceURL(rootPath, resourcePath, poolID, monitorPath, monitorID)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/requests.go b/openstack/networking/v2/extensions/lbaas/vips/requests.go
new file mode 100644
index 0000000..f89d769
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/requests.go
@@ -0,0 +1,163 @@
+package vips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	ID              string `q:"id"`
+	Name            string `q:"name"`
+	AdminStateUp    *bool  `q:"admin_state_up"`
+	Status          string `q:"status"`
+	TenantID        string `q:"tenant_id"`
+	SubnetID        string `q:"subnet_id"`
+	Address         string `q:"address"`
+	PortID          string `q:"port_id"`
+	Protocol        string `q:"protocol"`
+	ProtocolPort    int    `q:"protocol_port"`
+	ConnectionLimit int    `q:"connection_limit"`
+	Limit           int    `q:"limit"`
+	Marker          string `q:"marker"`
+	SortKey         string `q:"sort_key"`
+	SortDir         string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// routers. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those routers that are owned by the
+// tenant who submits the request, unless an admin user submits the request.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return VIPPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+	ToVIPCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new virtual IP.
+type CreateOpts struct {
+	// Human-readable name for the VIP. Does not have to be unique.
+	Name string `json:"name" required:"true"`
+	// The network on which to allocate the VIP's address. A tenant can
+	// only create VIPs on networks authorized by policy (e.g. networks that
+	// belong to them or networks that are shared).
+	SubnetID string `json:"subnet_id" required:"true"`
+	// The protocol - can either be TCP, HTTP or HTTPS.
+	Protocol string `json:"protocol" required:"true"`
+	// The port on which to listen for client traffic.
+	ProtocolPort int `json:"protocol_port" required:"true"`
+	// The ID of the pool with which the VIP is associated.
+	PoolID string `json:"pool_id" required:"true"`
+	// Required for admins. Indicates the owner of the VIP.
+	TenantID string `json:"tenant_id,omitempty"`
+	// The IP address of the VIP.
+	Address string `json:"address,omitempty"`
+	// Human-readable description for the VIP.
+	Description string `json:"description,omitempty"`
+	// Omit this field to prevent session persistence.
+	Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+	// The maximum number of connections allowed for the VIP.
+	ConnLimit *int `json:"connection_limit,omitempty"`
+	// The administrative state of the VIP. A valid value is true (UP)
+	// or false (DOWN).
+	AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToVIPCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToVIPCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "vip")
+}
+
+// Create is an operation which provisions a new virtual IP based on the
+// configuration defined in the CreateOpts struct. Once the request is
+// validated and progress has started on the provisioning process, a
+// CreateResult will be returned.
+//
+// Please note that the PoolID should refer to a pool that is not already
+// associated with another vip. If the pool is already used by another vip,
+// then the operation will fail with a 409 Conflict error will be returned.
+//
+// Users with an admin role can create VIPs on behalf of other tenants by
+// specifying a TenantID attribute different than their own.
+func Create(c *gophercloud.ServiceClient, opts CreateOpts) (r CreateResult) {
+	b, err := opts.ToVIPCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular virtual IP based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is what types must satisfy to be used as Update
+// options.
+type UpdateOptsBuilder interface {
+	ToVIPUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains all the values needed to update an existing virtual IP.
+// Attributes not listed here but appear in CreateOpts are immutable and cannot
+// be updated.
+type UpdateOpts struct {
+	// Human-readable name for the VIP. Does not have to be unique.
+	Name *string `json:"name,omitempty"`
+	// The ID of the pool with which the VIP is associated.
+	PoolID *string `json:"pool_id,omitempty"`
+	// Human-readable description for the VIP.
+	Description *string `json:"description,omitempty"`
+	// Omit this field to prevent session persistence.
+	Persistence *SessionPersistence `json:"session_persistence,omitempty"`
+	// The maximum number of connections allowed for the VIP.
+	ConnLimit *int `json:"connection_limit,omitempty"`
+	// The administrative state of the VIP. A valid value is true (UP)
+	// or false (DOWN).
+	AdminStateUp *bool `json:"admin_state_up,omitempty"`
+}
+
+// ToVIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder interface
+func (opts UpdateOpts) ToVIPUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "vip")
+}
+
+// Update is an operation which modifies the attributes of the specified VIP.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToVIPUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(resourceURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 202},
+	})
+	return
+}
+
+// Delete will permanently delete a particular virtual IP based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/requests_test.go b/openstack/networking/v2/extensions/lbaas/vips/requests_test.go
new file mode 100644
index 0000000..a7612da
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/requests_test.go
@@ -0,0 +1,337 @@
+package vips
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/lb/vips", rootURL(fake.ServiceClient()))
+	th.AssertEquals(t, th.Endpoint()+"v2.0/lb/vips/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/vips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+  "vips":[
+         {
+           "id": "db902c0c-d5ff-4753-b465-668ad9656918",
+           "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+           "name": "web_vip",
+           "description": "lb config for the web tier",
+           "subnet_id": "96a4386a-f8c3-42ed-afce-d7954eee77b3",
+           "address" : "10.30.176.47",
+           "port_id" : "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+           "protocol": "HTTP",
+           "protocol_port": 80,
+           "pool_id" : "cfc6589d-f949-4c66-99d2-c2da56ef3764",
+           "admin_state_up": true,
+           "status": "ACTIVE"
+         },
+         {
+           "id": "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+           "tenant_id": "310df60f-2a10-4ee5-9554-98393092194c",
+           "name": "db_vip",
+					 "description": "lb config for the db tier",
+           "subnet_id": "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+           "address" : "10.30.176.48",
+           "port_id" : "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+           "protocol": "TCP",
+           "protocol_port": 3306,
+           "pool_id" : "41efe233-7591-43c5-9cf7-923964759f9e",
+           "session_persistence" : {"type" : "SOURCE_IP"},
+           "connection_limit" : 2000,
+           "admin_state_up": true,
+           "status": "INACTIVE"
+         }
+      ]
+}
+			`)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractVIPs(page)
+		if err != nil {
+			t.Errorf("Failed to extract LBs: %v", err)
+			return false, err
+		}
+
+		expected := []VirtualIP{
+			VirtualIP{
+				ID:           "db902c0c-d5ff-4753-b465-668ad9656918",
+				TenantID:     "310df60f-2a10-4ee5-9554-98393092194c",
+				Name:         "web_vip",
+				Description:  "lb config for the web tier",
+				SubnetID:     "96a4386a-f8c3-42ed-afce-d7954eee77b3",
+				Address:      "10.30.176.47",
+				PortID:       "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+				Protocol:     "HTTP",
+				ProtocolPort: 80,
+				PoolID:       "cfc6589d-f949-4c66-99d2-c2da56ef3764",
+				Persistence:  SessionPersistence{},
+				ConnLimit:    0,
+				AdminStateUp: true,
+				Status:       "ACTIVE",
+			},
+			VirtualIP{
+				ID:           "36e08a3e-a78f-4b40-a229-1e7e23eee1ab",
+				TenantID:     "310df60f-2a10-4ee5-9554-98393092194c",
+				Name:         "db_vip",
+				Description:  "lb config for the db tier",
+				SubnetID:     "9cedb85d-0759-4898-8a4b-fa5a5ea10086",
+				Address:      "10.30.176.48",
+				PortID:       "cd1f7a47-4fa6-449c-9ee7-632838aedfea",
+				Protocol:     "TCP",
+				ProtocolPort: 3306,
+				PoolID:       "41efe233-7591-43c5-9cf7-923964759f9e",
+				Persistence:  SessionPersistence{Type: "SOURCE_IP"},
+				ConnLimit:    2000,
+				AdminStateUp: true,
+				Status:       "INACTIVE",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/vips", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "vip": {
+        "protocol": "HTTP",
+        "name": "NewVip",
+        "admin_state_up": true,
+        "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+        "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+        "protocol_port": 80,
+				"session_persistence": {"type": "SOURCE_IP"}
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "vip": {
+        "status": "PENDING_CREATE",
+        "protocol": "HTTP",
+        "description": "",
+        "admin_state_up": true,
+        "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+        "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+        "connection_limit": -1,
+        "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+        "address": "10.0.0.11",
+        "protocol_port": 80,
+        "port_id": "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5",
+        "id": "c987d2be-9a3c-4ac9-a046-e8716b1350e2",
+        "name": "NewVip"
+    }
+}
+		`)
+	})
+
+	opts := CreateOpts{
+		Protocol:     "HTTP",
+		Name:         "NewVip",
+		AdminStateUp: gophercloud.Enabled,
+		SubnetID:     "8032909d-47a1-4715-90af-5153ffe39861",
+		PoolID:       "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+		ProtocolPort: 80,
+		Persistence:  &SessionPersistence{Type: "SOURCE_IP"},
+	}
+
+	r, err := Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "PENDING_CREATE", r.Status)
+	th.AssertEquals(t, "HTTP", r.Protocol)
+	th.AssertEquals(t, "", r.Description)
+	th.AssertEquals(t, true, r.AdminStateUp)
+	th.AssertEquals(t, "8032909d-47a1-4715-90af-5153ffe39861", r.SubnetID)
+	th.AssertEquals(t, "83657cfcdfe44cd5920adaf26c48ceea", r.TenantID)
+	th.AssertEquals(t, -1, r.ConnLimit)
+	th.AssertEquals(t, "61b1f87a-7a21-4ad3-9dda-7f81d249944f", r.PoolID)
+	th.AssertEquals(t, "10.0.0.11", r.Address)
+	th.AssertEquals(t, 80, r.ProtocolPort)
+	th.AssertEquals(t, "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5", r.PortID)
+	th.AssertEquals(t, "c987d2be-9a3c-4ac9-a046-e8716b1350e2", r.ID)
+	th.AssertEquals(t, "NewVip", r.Name)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Name: "foo"})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar"})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar", Protocol: "bar"})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Name: "foo", SubnetID: "bar", Protocol: "bar", ProtocolPort: 80})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "vip": {
+        "status": "ACTIVE",
+        "protocol": "HTTP",
+        "description": "",
+        "admin_state_up": true,
+        "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+        "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+        "connection_limit": 1000,
+        "pool_id": "72741b06-df4d-4715-b142-276b6bce75ab",
+        "session_persistence": {
+            "cookie_name": "MyAppCookie",
+            "type": "APP_COOKIE"
+        },
+        "address": "10.0.0.10",
+        "protocol_port": 80,
+        "port_id": "b5a743d6-056b-468b-862d-fb13a9aa694e",
+        "id": "4ec89087-d057-4e2c-911f-60a3b47ee304",
+        "name": "my-vip"
+    }
+}
+			`)
+	})
+
+	vip, err := Get(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "ACTIVE", vip.Status)
+	th.AssertEquals(t, "HTTP", vip.Protocol)
+	th.AssertEquals(t, "", vip.Description)
+	th.AssertEquals(t, true, vip.AdminStateUp)
+	th.AssertEquals(t, 1000, vip.ConnLimit)
+	th.AssertEquals(t, SessionPersistence{Type: "APP_COOKIE", CookieName: "MyAppCookie"}, vip.Persistence)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "vip": {
+        "connection_limit": 1000,
+				"session_persistence": {"type": "SOURCE_IP"}
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+
+		fmt.Fprintf(w, `
+{
+    "vip": {
+        "status": "PENDING_UPDATE",
+        "protocol": "HTTP",
+        "description": "",
+        "admin_state_up": true,
+        "subnet_id": "8032909d-47a1-4715-90af-5153ffe39861",
+        "tenant_id": "83657cfcdfe44cd5920adaf26c48ceea",
+        "connection_limit": 1000,
+        "pool_id": "61b1f87a-7a21-4ad3-9dda-7f81d249944f",
+        "address": "10.0.0.11",
+        "protocol_port": 80,
+        "port_id": "f7e6fe6a-b8b5-43a8-8215-73456b32e0f5",
+        "id": "c987d2be-9a3c-4ac9-a046-e8716b1350e2",
+        "name": "NewVip"
+    }
+}
+		`)
+	})
+
+	i1000 := 1000
+	options := UpdateOpts{
+		ConnLimit:   &i1000,
+		Persistence: &SessionPersistence{Type: "SOURCE_IP"},
+	}
+	vip, err := Update(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "PENDING_UPDATE", vip.Status)
+	th.AssertEquals(t, 1000, vip.ConnLimit)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/lb/vips/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/results.go b/openstack/networking/v2/extensions/lbaas/vips/results.go
new file mode 100644
index 0000000..7ac7e79
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/results.go
@@ -0,0 +1,151 @@
+package vips
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// SessionPersistence represents the session persistence feature of the load
+// balancing service. It attempts to force connections or requests in the same
+// session to be processed by the same member as long as it is ative. Three
+// types of persistence are supported:
+//
+// SOURCE_IP:   With this mode, all connections originating from the same source
+//              IP address, will be handled by the same member of the pool.
+// HTTP_COOKIE: With this persistence mode, the load balancing function will
+//              create a cookie on the first request from a client. Subsequent
+//              requests containing the same cookie value will be handled by
+//              the same member of the pool.
+// APP_COOKIE:  With this persistence mode, the load balancing function will
+//              rely on a cookie established by the backend application. All
+//              requests carrying the same cookie value will be handled by the
+//              same member of the pool.
+type SessionPersistence struct {
+	// The type of persistence mode
+	Type string `json:"type"`
+
+	// Name of cookie if persistence mode is set appropriately
+	CookieName string `json:"cookie_name,omitempty"`
+}
+
+// VirtualIP is the primary load balancing configuration object that specifies
+// the virtual IP address and port on which client traffic is received, as well
+// as other details such as the load balancing method to be use, protocol, etc.
+// This entity is sometimes known in LB products under the name of a "virtual
+// server", a "vserver" or a "listener".
+type VirtualIP struct {
+	// The unique ID for the VIP.
+	ID string `json:"id"`
+
+	// Owner of the VIP. Only an admin user can specify a tenant ID other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// Human-readable name for the VIP. Does not have to be unique.
+	Name string `json:"name"`
+
+	// Human-readable description for the VIP.
+	Description string `json:"description"`
+
+	// The ID of the subnet on which to allocate the VIP address.
+	SubnetID string `json:"subnet_id"`
+
+	// The IP address of the VIP.
+	Address string `json:"address"`
+
+	// The protocol of the VIP address. A valid value is TCP, HTTP, or HTTPS.
+	Protocol string `json:"protocol"`
+
+	// The port on which to listen to client traffic that is associated with the
+	// VIP address. A valid value is from 0 to 65535.
+	ProtocolPort int `json:"protocol_port"`
+
+	// The ID of the pool with which the VIP is associated.
+	PoolID string `json:"pool_id"`
+
+	// The ID of the port which belongs to the load balancer
+	PortID string `json:"port_id"`
+
+	// Indicates whether connections in the same session will be processed by the
+	// same pool member or not.
+	Persistence SessionPersistence `json:"session_persistence"`
+
+	// The maximum number of connections allowed for the VIP. Default is -1,
+	// meaning no limit.
+	ConnLimit int `json:"connection_limit"`
+
+	// The administrative state of the VIP. A valid value is true (UP) or false (DOWN).
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// The status of the VIP. Indicates whether the VIP is operational.
+	Status string `json:"status"`
+}
+
+// VIPPage is the page returned by a pager when traversing over a
+// collection of routers.
+type VIPPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of routers has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r VIPPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"vips_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a VIPPage struct is empty.
+func (r VIPPage) IsEmpty() (bool, error) {
+	is, err := ExtractVIPs(r)
+	return len(is) == 0, err
+}
+
+// ExtractVIPs accepts a Page struct, specifically a VIPPage struct,
+// and extracts the elements into a slice of VirtualIP structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractVIPs(r pagination.Page) ([]VirtualIP, error) {
+	var s struct {
+		VIPs []VirtualIP `json:"vips"`
+	}
+	err := (r.(VIPPage)).ExtractInto(&s)
+	return s.VIPs, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a router.
+func (r commonResult) Extract() (*VirtualIP, error) {
+	var s struct {
+		VirtualIP *VirtualIP `json:"vip" json:"vip"`
+	}
+	err := r.ExtractInto(&s)
+	return s.VirtualIP, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/lbaas/vips/urls.go b/openstack/networking/v2/extensions/lbaas/vips/urls.go
new file mode 100644
index 0000000..584a1cf
--- /dev/null
+++ b/openstack/networking/v2/extensions/lbaas/vips/urls.go
@@ -0,0 +1,16 @@
+package vips
+
+import "github.com/gophercloud/gophercloud"
+
+const (
+	rootPath     = "lb"
+	resourcePath = "vips"
+)
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath, resourcePath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, resourcePath, id)
+}
diff --git a/openstack/networking/v2/extensions/provider/doc.go b/openstack/networking/v2/extensions/provider/doc.go
new file mode 100755
index 0000000..373da44
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/doc.go
@@ -0,0 +1,21 @@
+// Package provider gives access to the provider Neutron plugin, allowing
+// network extended attributes. The provider extended attributes for networks
+// enable administrative users to specify how network objects map to the
+// underlying networking infrastructure. These extended attributes also appear
+// when administrative users query networks.
+//
+// For more information about extended attributes, see the NetworkExtAttrs
+// struct. The actual semantics of these attributes depend on the technology
+// back end of the particular plug-in. See the plug-in documentation and the
+// OpenStack Cloud Administrator Guide to understand which values should be
+// specific for each of these attributes when OpenStack Networking is deployed
+// with a particular plug-in. The examples shown in this chapter refer to the
+// Open vSwitch plug-in.
+//
+// The default policy settings enable only users with administrative rights to
+// specify these parameters in requests and to see their values in responses. By
+// default, the provider network extension attributes are completely hidden from
+// regular tenants. As a rule of thumb, if these attributes are not visible in a
+// GET /networks/<network-id> operation, this implies the user submitting the
+// request is not authorized to view or manipulate provider network attributes.
+package provider
diff --git a/openstack/networking/v2/extensions/provider/results.go b/openstack/networking/v2/extensions/provider/results.go
new file mode 100755
index 0000000..229013b
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/results.go
@@ -0,0 +1,90 @@
+package provider
+
+import (
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// NetworkExtAttrs represents an extended form of a Network with additional fields.
+type NetworkExtAttrs struct {
+	// UUID for the network
+	ID string `json:"id"`
+
+	// Human-readable name for the network. Might not be unique.
+	Name string `json:"name"`
+
+	// The administrative state of network. If false (down), the network does not forward packets.
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `json:"status"`
+
+	// Subnets associated with this network.
+	Subnets []string `json:"subnets"`
+
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// Specifies whether the network resource can be accessed by any tenant or not.
+	Shared bool `json:"shared"`
+
+	// Specifies the nature of the physical network mapped to this network
+	// resource. Examples are flat, vlan, or gre.
+	NetworkType string `json:"provider:network_type"`
+
+	// Identifies the physical network on top of which this network object is
+	// being implemented. The OpenStack Networking API does not expose any facility
+	// for retrieving the list of available physical networks. As an example, in
+	// the Open vSwitch plug-in this is a symbolic name which is then mapped to
+	// specific bridges on each compute host through the Open vSwitch plug-in
+	// configuration file.
+	PhysicalNetwork string `json:"provider:physical_network"`
+
+	// Identifies an isolated segment on the physical network; the nature of the
+	// segment depends on the segmentation model defined by network_type. For
+	// instance, if network_type is vlan, then this is a vlan identifier;
+	// otherwise, if network_type is gre, then this will be a gre key.
+	SegmentationID string `json:"provider:segmentation_id"`
+}
+
+// ExtractGet decorates a GetResult struct returned from a networks.Get()
+// function with extended attributes.
+func ExtractGet(r networks.GetResult) (*NetworkExtAttrs, error) {
+	var s struct {
+		Network *NetworkExtAttrs `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractCreate decorates a CreateResult struct returned from a networks.Create()
+// function with extended attributes.
+func ExtractCreate(r networks.CreateResult) (*NetworkExtAttrs, error) {
+	var s struct {
+		Network *NetworkExtAttrs `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractUpdate decorates a UpdateResult struct returned from a
+// networks.Update() function with extended attributes.
+func ExtractUpdate(r networks.UpdateResult) (*NetworkExtAttrs, error) {
+	var s struct {
+		Network *NetworkExtAttrs `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// ExtractList accepts a Page struct, specifically a NetworkPage struct, and
+// extracts the elements into a slice of NetworkExtAttrs structs. In other
+// words, a generic collection is mapped into a relevant slice.
+func ExtractList(r pagination.Page) ([]NetworkExtAttrs, error) {
+	var s struct {
+		Networks []NetworkExtAttrs `json:"networks" json:"networks"`
+	}
+	err := (r.(networks.NetworkPage)).ExtractInto(&s)
+	return s.Networks, err
+}
diff --git a/openstack/networking/v2/extensions/provider/results_test.go b/openstack/networking/v2/extensions/provider/results_test.go
new file mode 100644
index 0000000..d41e39e
--- /dev/null
+++ b/openstack/networking/v2/extensions/provider/results_test.go
@@ -0,0 +1,254 @@
+package provider
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/networks"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "networks": [
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+            ],
+            "name": "private-network",
+            "admin_state_up": true,
+            "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+            "shared": true,
+            "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+            "provider:segmentation_id": null,
+            "provider:physical_network": null,
+            "provider:network_type": "local"
+        },
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "08eae331-0402-425a-923c-34f7cfe39c1b"
+            ],
+            "name": "private",
+            "admin_state_up": true,
+            "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+            "shared": true,
+            "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+            "provider:segmentation_id": "1234567890",
+            "provider:physical_network": null,
+            "provider:network_type": "local"
+        }
+    ]
+}
+			`)
+	})
+
+	count := 0
+
+	networks.List(fake.ServiceClient(), networks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractList(page)
+		if err != nil {
+			t.Errorf("Failed to extract networks: %v", err)
+			return false, err
+		}
+
+		expected := []NetworkExtAttrs{
+			NetworkExtAttrs{
+				Status:          "ACTIVE",
+				Subnets:         []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+				Name:            "private-network",
+				AdminStateUp:    true,
+				TenantID:        "4fd44f30292945e481c7b8a0c8908869",
+				Shared:          true,
+				ID:              "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+				NetworkType:     "local",
+				PhysicalNetwork: "",
+				SegmentationID:  "",
+			},
+			NetworkExtAttrs{
+				Status:          "ACTIVE",
+				Subnets:         []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+				Name:            "private",
+				AdminStateUp:    true,
+				TenantID:        "26a7980765d0414dbc1fc1f88cdb7e6e",
+				Shared:          true,
+				ID:              "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+				NetworkType:     "local",
+				PhysicalNetwork: "",
+				SegmentationID:  "1234567890",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [
+            "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        ],
+        "name": "private-network",
+        "provider:physical_network": null,
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "provider:network_type": "local",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "provider:segmentation_id": null
+    }
+}
+			`)
+	})
+
+	res := networks.Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	n, err := ExtractGet(res)
+
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "", n.PhysicalNetwork)
+	th.AssertEquals(t, "local", n.NetworkType)
+	th.AssertEquals(t, "", n.SegmentationID)
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "network": {
+        "name": "sample_network",
+        "admin_state_up": true
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [
+            "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        ],
+        "name": "private-network",
+        "provider:physical_network": null,
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "provider:network_type": "local",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "provider:segmentation_id": null
+    }
+}
+		`)
+	})
+
+	options := networks.CreateOpts{Name: "sample_network", AdminStateUp: gophercloud.Enabled}
+	res := networks.Create(fake.ServiceClient(), options)
+	n, err := ExtractCreate(res)
+
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "", n.PhysicalNetwork)
+	th.AssertEquals(t, "local", n.NetworkType)
+	th.AssertEquals(t, "", n.SegmentationID)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"network": {
+				"name": "new_network_name",
+				"admin_state_up": false,
+				"shared": true
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [
+            "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        ],
+        "name": "private-network",
+        "provider:physical_network": null,
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "provider:network_type": "local",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "provider:segmentation_id": null
+    }
+}
+		`)
+	})
+
+	iTrue := true
+	options := networks.UpdateOpts{Name: "new_network_name", AdminStateUp: gophercloud.Disabled, Shared: &iTrue}
+	res := networks.Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options)
+	n, err := ExtractUpdate(res)
+
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "", n.PhysicalNetwork)
+	th.AssertEquals(t, "local", n.NetworkType)
+	th.AssertEquals(t, "", n.SegmentationID)
+}
diff --git a/openstack/networking/v2/extensions/security/doc.go b/openstack/networking/v2/extensions/security/doc.go
new file mode 100644
index 0000000..31f744c
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/doc.go
@@ -0,0 +1,32 @@
+// Package security contains functionality to work with security group and
+// security group rules Neutron resources.
+//
+// Security groups and security group rules allows administrators and tenants
+// the ability to specify the type of traffic and direction (ingress/egress)
+// that is allowed to pass through a port. A security group is a container for
+// security group rules.
+//
+// When a port is created in Networking it is associated with a security group.
+// If a security group is not specified the port is associated with a 'default'
+// security group. By default, this group drops all ingress traffic and allows
+// all egress. Rules can be added to this group in order to change the behaviour.
+//
+// The basic characteristics of Neutron Security Groups are:
+//
+// For ingress traffic (to an instance)
+//  - Only traffic matched with security group rules are allowed.
+//  - When there is no rule defined, all traffic is dropped.
+//
+// For egress traffic (from an instance)
+//  - Only traffic matched with security group rules are allowed.
+//  - When there is no rule defined, all egress traffic are dropped.
+//  - When a new security group is created, rules to allow all egress traffic
+//    is automatically added.
+//
+// "default security group" is defined for each tenant.
+//  - For the default security group a rule which allows intercommunication
+//    among hosts associated with the default security group is defined by default.
+//  - As a result, all egress traffic and intercommunication in the default
+//    group are allowed and all ingress from outside of the default group is
+//    dropped by default (in the default security group).
+package security
diff --git a/openstack/networking/v2/extensions/security/groups/requests.go b/openstack/networking/v2/extensions/security/groups/requests.go
new file mode 100644
index 0000000..5ca4850
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/requests.go
@@ -0,0 +1,108 @@
+package groups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the floating IP attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	ID       string `q:"id"`
+	Name     string `q:"name"`
+	TenantID string `q:"tenant_id"`
+	Limit    int    `q:"limit"`
+	Marker   string `q:"marker"`
+	SortKey  string `q:"sort_key"`
+	SortDir  string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// security groups. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return SecGroupPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+type CreateOptsBuilder interface {
+	ToSecGroupCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new security group.
+type CreateOpts struct {
+	// Required. Human-readable name for the VIP. Does not have to be unique.
+	Name string `json:"name" required:"true"`
+	// Required for admins. Indicates the owner of the VIP.
+	TenantID string `json:"tenant_id,omitempty"`
+	// Optional. Describes the security group.
+	Description string `json:"description,omitempty"`
+}
+
+func (opts CreateOpts) ToSecGroupCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group")
+}
+
+// Create is an operation which provisions a new security group with default
+// security group rules for the IPv4 and IPv6 ether types.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToSecGroupCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular security group based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// Delete will permanently delete a particular security group based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
+
+// IDFromName is a convenience function that returns a security group's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, ListOpts{}).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractGroups(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "security group"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "security group"}
+	}
+}
diff --git a/openstack/networking/v2/extensions/security/groups/requests_test.go b/openstack/networking/v2/extensions/security/groups/requests_test.go
new file mode 100644
index 0000000..4519e6c
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/requests_test.go
@@ -0,0 +1,213 @@
+package groups
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/security-groups", rootURL(fake.ServiceClient()))
+	th.AssertEquals(t, th.Endpoint()+"v2.0/security-groups/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-groups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "security_groups": [
+        {
+            "description": "default",
+            "id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+            "name": "default",
+            "security_group_rules": [],
+            "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractGroups(page)
+		if err != nil {
+			t.Errorf("Failed to extract secgroups: %v", err)
+			return false, err
+		}
+
+		expected := []SecGroup{
+			SecGroup{
+				Description: "default",
+				ID:          "85cc3048-abc3-43cc-89b3-377341426ac5",
+				Name:        "default",
+				Rules:       []rules.SecGroupRule{},
+				TenantID:    "e4f50856753b4dc6afee5fa6b9b6c550",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-groups", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "security_group": {
+        "name": "new-webservers",
+        "description": "security group for webservers"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "security_group": {
+        "description": "security group for webservers",
+        "id": "2076db17-a522-4506-91de-c6dd8e837028",
+        "name": "new-webservers",
+        "security_group_rules": [
+            {
+                "direction": "egress",
+                "ethertype": "IPv4",
+                "id": "38ce2d8e-e8f1-48bd-83c2-d33cb9f50c3d",
+                "port_range_max": null,
+                "port_range_min": null,
+                "protocol": null,
+                "remote_group_id": null,
+                "remote_ip_prefix": null,
+                "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+                "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+            },
+            {
+                "direction": "egress",
+                "ethertype": "IPv6",
+                "id": "565b9502-12de-4ffd-91e9-68885cff6ae1",
+                "port_range_max": null,
+                "port_range_min": null,
+                "protocol": null,
+                "remote_group_id": null,
+                "remote_ip_prefix": null,
+                "security_group_id": "2076db17-a522-4506-91de-c6dd8e837028",
+                "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+            }
+        ],
+        "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+    }
+}
+    `)
+	})
+
+	opts := CreateOpts{Name: "new-webservers", Description: "security group for webservers"}
+	_, err := Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-groups/85cc3048-abc3-43cc-89b3-377341426ac5", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "security_group": {
+        "description": "default",
+        "id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+        "name": "default",
+        "security_group_rules": [
+            {
+                "direction": "egress",
+                "ethertype": "IPv6",
+                "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+                "port_range_max": null,
+                "port_range_min": null,
+                "protocol": null,
+                "remote_group_id": null,
+                "remote_ip_prefix": null,
+                "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+                "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+            },
+            {
+                "direction": "egress",
+                "ethertype": "IPv4",
+                "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+                "port_range_max": null,
+                "port_range_min": null,
+                "protocol": null,
+                "remote_group_id": null,
+                "remote_ip_prefix": null,
+                "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+                "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+            }
+        ],
+        "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+    }
+}
+      `)
+	})
+
+	sg, err := Get(fake.ServiceClient(), "85cc3048-abc3-43cc-89b3-377341426ac5").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "default", sg.Description)
+	th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sg.ID)
+	th.AssertEquals(t, "default", sg.Name)
+	th.AssertEquals(t, 2, len(sg.Rules))
+	th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sg.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-groups/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/security/groups/results.go b/openstack/networking/v2/extensions/security/groups/results.go
new file mode 100644
index 0000000..ea3ad65
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/results.go
@@ -0,0 +1,94 @@
+package groups
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/security/rules"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// SecGroup represents a container for security group rules.
+type SecGroup struct {
+	// The UUID for the security group.
+	ID string
+
+	// Human-readable name for the security group. Might not be unique. Cannot be
+	// named "default" as that is automatically created for a tenant.
+	Name string
+
+	// The security group description.
+	Description string
+
+	// A slice of security group rules that dictate the permitted behaviour for
+	// traffic entering and leaving the group.
+	Rules []rules.SecGroupRule `json:"security_group_rules"`
+
+	// Owner of the security group. Only admin users can specify a TenantID
+	// other than their own.
+	TenantID string `json:"tenant_id"`
+}
+
+// SecGroupPage is the page returned by a pager when traversing over a
+// collection of security groups.
+type SecGroupPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of security groups has
+// reached the end of a page and the pager seeks to traverse over a new one. In
+// order to do this, it needs to construct the next page's URL.
+func (r SecGroupPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"security_groups_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SecGroupPage struct is empty.
+func (r SecGroupPage) IsEmpty() (bool, error) {
+	is, err := ExtractGroups(r)
+	return len(is) == 0, err
+}
+
+// ExtractGroups accepts a Page struct, specifically a SecGroupPage struct,
+// and extracts the elements into a slice of SecGroup structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractGroups(r pagination.Page) ([]SecGroup, error) {
+	var s struct {
+		SecGroups []SecGroup `json:"security_groups"`
+	}
+	err := (r.(SecGroupPage)).ExtractInto(&s)
+	return s.SecGroups, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a security group.
+func (r commonResult) Extract() (*SecGroup, error) {
+	var s struct {
+		SecGroup *SecGroup `json:"security_group"`
+	}
+	err := r.ExtractInto(&s)
+	return s.SecGroup, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/security/groups/urls.go b/openstack/networking/v2/extensions/security/groups/urls.go
new file mode 100644
index 0000000..104cbcc
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/groups/urls.go
@@ -0,0 +1,13 @@
+package groups
+
+import "github.com/gophercloud/gophercloud"
+
+const rootPath = "security-groups"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, id)
+}
diff --git a/openstack/networking/v2/extensions/security/rules/requests.go b/openstack/networking/v2/extensions/security/rules/requests.go
new file mode 100644
index 0000000..77f7e37
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/requests.go
@@ -0,0 +1,127 @@
+package rules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the security group attributes you want to see returned. SortKey allows you to
+// sort by a particular network attribute. SortDir sets the direction, and is
+// either `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Direction      string `q:"direction"`
+	EtherType      string `q:"ethertype"`
+	ID             string `q:"id"`
+	PortRangeMax   int    `q:"port_range_max"`
+	PortRangeMin   int    `q:"port_range_min"`
+	Protocol       string `q:"protocol"`
+	RemoteGroupID  string `q:"remote_group_id"`
+	RemoteIPPrefix string `q:"remote_ip_prefix"`
+	SecGroupID     string `q:"security_group_id"`
+	TenantID       string `q:"tenant_id"`
+	Limit          int    `q:"limit"`
+	Marker         string `q:"marker"`
+	SortKey        string `q:"sort_key"`
+	SortDir        string `q:"sort_dir"`
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// security group rules. It accepts a ListOpts struct, which allows you to filter
+// and sort the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOpts) pagination.Pager {
+	q, err := gophercloud.BuildQueryString(&opts)
+	if err != nil {
+		return pagination.Pager{Err: err}
+	}
+	u := rootURL(c) + q.String()
+	return pagination.NewPager(c, u, func(r pagination.PageResult) pagination.Page {
+		return SecGroupRulePage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+type RuleDirection string
+type RuleProtocol string
+type RuleEtherType string
+
+// Constants useful for CreateOpts
+const (
+	DirIngress   RuleDirection = "ingress"
+	DirEgress    RuleDirection = "egress"
+	ProtocolTCP  RuleProtocol  = "tcp"
+	ProtocolUDP  RuleProtocol  = "udp"
+	ProtocolICMP RuleProtocol  = "icmp"
+	EtherType4   RuleEtherType = "IPv4"
+	EtherType6   RuleEtherType = "IPv6"
+)
+
+// CreateOptsBuilder is what types must satisfy to be used as Create
+// options.
+type CreateOptsBuilder interface {
+	ToSecGroupRuleCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts contains all the values needed to create a new security group rule.
+type CreateOpts struct {
+	// Required. Must be either "ingress" or "egress": the direction in which the
+	// security group rule is applied.
+	Direction RuleDirection `json:"direction" required:"true"`
+	// Required. Must be "IPv4" or "IPv6", and addresses represented in CIDR must
+	// match the ingress or egress rules.
+	EtherType RuleEtherType `json:"ethertype" required:"true"`
+	// Required. The security group ID to associate with this security group rule.
+	SecGroupID string `json:"security_group_id" required:"true"`
+	// Optional. The maximum port number in the range that is matched by the
+	// security group rule. The PortRangeMin attribute constrains the PortRangeMax
+	// attribute. If the protocol is ICMP, this value must be an ICMP type.
+	PortRangeMax int `json:"port_range_max,omitempty"`
+	// Optional. The minimum port number in the range that is matched by the
+	// security group rule. If the protocol is TCP or UDP, this value must be
+	// less than or equal to the value of the PortRangeMax attribute. If the
+	// protocol is ICMP, this value must be an ICMP type.
+	PortRangeMin int `json:"port_range_min,omitempty"`
+	// Optional. The protocol that is matched by the security group rule. Valid
+	// values are "tcp", "udp", "icmp" or an empty string.
+	Protocol RuleProtocol `json:"protocol,omitempty"`
+	// Optional. The remote group ID to be associated with this security group
+	// rule. You can specify either RemoteGroupID or RemoteIPPrefix.
+	RemoteGroupID string `json:"remote_group_id,omitempty"`
+	// Optional. The remote IP prefix to be associated with this security group
+	// rule. You can specify either RemoteGroupID or RemoteIPPrefix. This
+	// attribute matches the specified IP prefix as the source IP address of the
+	// IP packet.
+	RemoteIPPrefix string `json:"remote_ip_prefix,omitempty"`
+	// Required for admins. Indicates the owner of the VIP.
+	TenantID string `json:"tenant_id,omitempty"`
+}
+
+// ToSecGroupRuleCreateMap allows CreateOpts to satisfy the CreateOptsBuilder
+// interface
+func (opts CreateOpts) ToSecGroupRuleCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "security_group_rule")
+}
+
+// Create is an operation which adds a new security group rule and associates it
+// with an existing security group (whose ID is specified in CreateOpts).
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToSecGroupRuleCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(rootURL(c), b, &r.Body, nil)
+	return
+}
+
+// Get retrieves a particular security group rule based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(resourceURL(c, id), &r.Body, nil)
+	return
+}
+
+// Delete will permanently delete a particular security group rule based on its unique ID.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(resourceURL(c, id), nil)
+	return
+}
diff --git a/openstack/networking/v2/extensions/security/rules/requests_test.go b/openstack/networking/v2/extensions/security/rules/requests_test.go
new file mode 100644
index 0000000..974b3ce
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/requests_test.go
@@ -0,0 +1,243 @@
+package rules
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestURLs(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.AssertEquals(t, th.Endpoint()+"v2.0/security-group-rules", rootURL(fake.ServiceClient()))
+	th.AssertEquals(t, th.Endpoint()+"v2.0/security-group-rules/foo", resourceURL(fake.ServiceClient(), "foo"))
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-group-rules", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "security_group_rules": [
+        {
+            "direction": "egress",
+            "ethertype": "IPv6",
+            "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+            "port_range_max": null,
+            "port_range_min": null,
+            "protocol": null,
+            "remote_group_id": null,
+            "remote_ip_prefix": null,
+            "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+            "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+        },
+        {
+            "direction": "egress",
+            "ethertype": "IPv4",
+            "id": "93aa42e5-80db-4581-9391-3a608bd0e448",
+            "port_range_max": null,
+            "port_range_min": null,
+            "protocol": null,
+            "remote_group_id": null,
+            "remote_ip_prefix": null,
+            "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+            "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractRules(page)
+		if err != nil {
+			t.Errorf("Failed to extract secrules: %v", err)
+			return false, err
+		}
+
+		expected := []SecGroupRule{
+			SecGroupRule{
+				Direction:      "egress",
+				EtherType:      "IPv6",
+				ID:             "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+				PortRangeMax:   0,
+				PortRangeMin:   0,
+				Protocol:       "",
+				RemoteGroupID:  "",
+				RemoteIPPrefix: "",
+				SecGroupID:     "85cc3048-abc3-43cc-89b3-377341426ac5",
+				TenantID:       "e4f50856753b4dc6afee5fa6b9b6c550",
+			},
+			SecGroupRule{
+				Direction:      "egress",
+				EtherType:      "IPv4",
+				ID:             "93aa42e5-80db-4581-9391-3a608bd0e448",
+				PortRangeMax:   0,
+				PortRangeMin:   0,
+				Protocol:       "",
+				RemoteGroupID:  "",
+				RemoteIPPrefix: "",
+				SecGroupID:     "85cc3048-abc3-43cc-89b3-377341426ac5",
+				TenantID:       "e4f50856753b4dc6afee5fa6b9b6c550",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-group-rules", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "security_group_rule": {
+        "direction": "ingress",
+        "port_range_min": 80,
+        "ethertype": "IPv4",
+        "port_range_max": 80,
+        "protocol": "tcp",
+        "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+        "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a"
+    }
+}
+      `)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "security_group_rule": {
+        "direction": "ingress",
+        "ethertype": "IPv4",
+        "id": "2bc0accf-312e-429a-956e-e4407625eb62",
+        "port_range_max": 80,
+        "port_range_min": 80,
+        "protocol": "tcp",
+        "remote_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+        "remote_ip_prefix": null,
+        "security_group_id": "a7734e61-b545-452d-a3cd-0189cbd9747a",
+        "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+    }
+}
+    `)
+	})
+
+	opts := CreateOpts{
+		Direction:     "ingress",
+		PortRangeMin:  80,
+		EtherType:     EtherType4,
+		PortRangeMax:  80,
+		Protocol:      "tcp",
+		RemoteGroupID: "85cc3048-abc3-43cc-89b3-377341426ac5",
+		SecGroupID:    "a7734e61-b545-452d-a3cd-0189cbd9747a",
+	}
+	_, err := Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: EtherType4})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: EtherType4})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+	res = Create(fake.ServiceClient(), CreateOpts{Direction: DirIngress, EtherType: EtherType4, SecGroupID: "something", Protocol: "foo"})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-group-rules/3c0e45ff-adaf-4124-b083-bf390e5482ff", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "security_group_rule": {
+        "direction": "egress",
+        "ethertype": "IPv6",
+        "id": "3c0e45ff-adaf-4124-b083-bf390e5482ff",
+        "port_range_max": null,
+        "port_range_min": null,
+        "protocol": null,
+        "remote_group_id": null,
+        "remote_ip_prefix": null,
+        "security_group_id": "85cc3048-abc3-43cc-89b3-377341426ac5",
+        "tenant_id": "e4f50856753b4dc6afee5fa6b9b6c550"
+    }
+}
+      `)
+	})
+
+	sr, err := Get(fake.ServiceClient(), "3c0e45ff-adaf-4124-b083-bf390e5482ff").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, "egress", sr.Direction)
+	th.AssertEquals(t, "IPv6", sr.EtherType)
+	th.AssertEquals(t, "3c0e45ff-adaf-4124-b083-bf390e5482ff", sr.ID)
+	th.AssertEquals(t, 0, sr.PortRangeMax)
+	th.AssertEquals(t, 0, sr.PortRangeMin)
+	th.AssertEquals(t, "", sr.Protocol)
+	th.AssertEquals(t, "", sr.RemoteGroupID)
+	th.AssertEquals(t, "", sr.RemoteIPPrefix)
+	th.AssertEquals(t, "85cc3048-abc3-43cc-89b3-377341426ac5", sr.SecGroupID)
+	th.AssertEquals(t, "e4f50856753b4dc6afee5fa6b9b6c550", sr.TenantID)
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/security-group-rules/4ec89087-d057-4e2c-911f-60a3b47ee304", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4ec89087-d057-4e2c-911f-60a3b47ee304")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/extensions/security/rules/results.go b/openstack/networking/v2/extensions/security/rules/results.go
new file mode 100644
index 0000000..18476a6
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/results.go
@@ -0,0 +1,118 @@
+package rules
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// SecGroupRule represents a rule to dictate the behaviour of incoming or
+// outgoing traffic for a particular security group.
+type SecGroupRule struct {
+	// The UUID for this security group rule.
+	ID string
+
+	// The direction in which the security group rule is applied. The only values
+	// allowed are "ingress" or "egress". For a compute instance, an ingress
+	// security group rule is applied to incoming (ingress) traffic for that
+	// instance. An egress rule is applied to traffic leaving the instance.
+	Direction string
+
+	// Must be IPv4 or IPv6, and addresses represented in CIDR must match the
+	// ingress or egress rules.
+	EtherType string `json:"ethertype"`
+
+	// The security group ID to associate with this security group rule.
+	SecGroupID string `json:"security_group_id"`
+
+	// The minimum port number in the range that is matched by the security group
+	// rule. If the protocol is TCP or UDP, this value must be less than or equal
+	// to the value of the PortRangeMax attribute. If the protocol is ICMP, this
+	// value must be an ICMP type.
+	PortRangeMin int `json:"port_range_min"`
+
+	// The maximum port number in the range that is matched by the security group
+	// rule. The PortRangeMin attribute constrains the PortRangeMax attribute. If
+	// the protocol is ICMP, this value must be an ICMP type.
+	PortRangeMax int `json:"port_range_max"`
+
+	// The protocol that is matched by the security group rule. Valid values are
+	// "tcp", "udp", "icmp" or an empty string.
+	Protocol string
+
+	// The remote group ID to be associated with this security group rule. You
+	// can specify either RemoteGroupID or RemoteIPPrefix.
+	RemoteGroupID string `json:"remote_group_id"`
+
+	// The remote IP prefix to be associated with this security group rule. You
+	// can specify either RemoteGroupID or RemoteIPPrefix . This attribute
+	// matches the specified IP prefix as the source IP address of the IP packet.
+	RemoteIPPrefix string `json:"remote_ip_prefix"`
+
+	// The owner of this security group rule.
+	TenantID string `json:"tenant_id"`
+}
+
+// SecGroupRulePage is the page returned by a pager when traversing over a
+// collection of security group rules.
+type SecGroupRulePage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of security group rules has
+// reached the end of a page and the pager seeks to traverse over a new one. In
+// order to do this, it needs to construct the next page's URL.
+func (r SecGroupRulePage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"security_group_rules_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SecGroupRulePage struct is empty.
+func (r SecGroupRulePage) IsEmpty() (bool, error) {
+	is, err := ExtractRules(r)
+	return len(is) == 0, err
+}
+
+// ExtractRules accepts a Page struct, specifically a SecGroupRulePage struct,
+// and extracts the elements into a slice of SecGroupRule structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractRules(r pagination.Page) ([]SecGroupRule, error) {
+	var s struct {
+		SecGroupRules []SecGroupRule `json:"security_group_rules"`
+	}
+	err := (r.(SecGroupRulePage)).ExtractInto(&s)
+	return s.SecGroupRules, err
+}
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a security rule.
+func (r commonResult) Extract() (*SecGroupRule, error) {
+	var s struct {
+		SecGroupRule *SecGroupRule `json:"security_group_rule"`
+	}
+	err := r.ExtractInto(&s)
+	return s.SecGroupRule, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
diff --git a/openstack/networking/v2/extensions/security/rules/urls.go b/openstack/networking/v2/extensions/security/rules/urls.go
new file mode 100644
index 0000000..a5ede0e
--- /dev/null
+++ b/openstack/networking/v2/extensions/security/rules/urls.go
@@ -0,0 +1,13 @@
+package rules
+
+import "github.com/gophercloud/gophercloud"
+
+const rootPath = "security-group-rules"
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL(rootPath)
+}
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL(rootPath, id)
+}
diff --git a/openstack/networking/v2/networks/doc.go b/openstack/networking/v2/networks/doc.go
new file mode 100644
index 0000000..c87a7ce
--- /dev/null
+++ b/openstack/networking/v2/networks/doc.go
@@ -0,0 +1,9 @@
+// Package networks contains functionality for working with Neutron network
+// resources. A network is an isolated virtual layer-2 broadcast domain that is
+// typically reserved for the tenant who created it (unless you configure the
+// network to be shared). Tenants can create multiple networks until the
+// thresholds per-tenant quota is reached.
+//
+// In the v2.0 Networking API, the network is the main entity. Ports and subnets
+// are always associated with a network.
+package networks
diff --git a/openstack/networking/v2/networks/errors.go b/openstack/networking/v2/networks/errors.go
new file mode 100644
index 0000000..83c4a6a
--- /dev/null
+++ b/openstack/networking/v2/networks/errors.go
@@ -0,0 +1 @@
+package networks
diff --git a/openstack/networking/v2/networks/requests.go b/openstack/networking/v2/networks/requests.go
new file mode 100644
index 0000000..876a00b
--- /dev/null
+++ b/openstack/networking/v2/networks/requests.go
@@ -0,0 +1,168 @@
+package networks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToNetworkListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the network attributes you want to see returned. SortKey allows you to sort
+// by a particular network attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status       string `q:"status"`
+	Name         string `q:"name"`
+	AdminStateUp *bool  `q:"admin_state_up"`
+	TenantID     string `q:"tenant_id"`
+	Shared       *bool  `q:"shared"`
+	ID           string `q:"id"`
+	Marker       string `q:"marker"`
+	Limit        int    `q:"limit"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// ToNetworkListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToNetworkListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// networks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToNetworkListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return NetworkPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get retrieves a specific network based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+	return
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToNetworkCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts satisfies the CreateOptsBuilder interface
+type CreateOpts struct {
+	AdminStateUp *bool  `json:"admin_state_up,omitempty"`
+	Name         string `json:"name,omitempty"`
+	Shared       *bool  `json:"shared,omitempty"`
+	TenantID     string `json:"tenant_id,omitempty"`
+}
+
+// ToNetworkCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToNetworkCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. This operation does not actually require a request body, i.e. the
+// CreateOpts struct argument can be empty.
+//
+// The tenant ID that is contained in the URI is the tenant that creates the
+// network. An admin user, however, has the option of specifying another tenant
+// ID in the CreateOpts struct.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToNetworkCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+	ToNetworkUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts satisfies the UpdateOptsBuilder interface
+type UpdateOpts struct {
+	AdminStateUp *bool  `json:"admin_state_up,omitempty"`
+	Name         string `json:"name,omitempty"`
+	Shared       *bool  `json:"shared,omitempty"`
+}
+
+// ToNetworkUpdateMap casts a UpdateOpts struct to a map.
+func (opts UpdateOpts) ToNetworkUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "network")
+}
+
+// Update accepts a UpdateOpts struct and updates an existing network using the
+// values provided. For more information, see the Create function.
+func Update(c *gophercloud.ServiceClient, networkID string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToNetworkUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(updateURL(c, networkID), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete accepts a unique ID and deletes the network associated with it.
+func Delete(c *gophercloud.ServiceClient, networkID string) (r DeleteResult) {
+	_, r.Err = c.Delete(deleteURL(c, networkID), nil)
+	return
+}
+
+// IDFromName is a convenience function that returns a network's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractNetworks(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "network"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "network"}
+	}
+}
diff --git a/openstack/networking/v2/networks/requests_test.go b/openstack/networking/v2/networks/requests_test.go
new file mode 100644
index 0000000..c5660ac
--- /dev/null
+++ b/openstack/networking/v2/networks/requests_test.go
@@ -0,0 +1,276 @@
+package networks
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "networks": [
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+            ],
+            "name": "private-network",
+            "admin_state_up": true,
+            "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+            "shared": true,
+            "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+        },
+        {
+            "status": "ACTIVE",
+            "subnets": [
+                "08eae331-0402-425a-923c-34f7cfe39c1b"
+            ],
+            "name": "private",
+            "admin_state_up": true,
+            "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+            "shared": true,
+            "id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324"
+        }
+    ]
+}
+			`)
+	})
+
+	client := fake.ServiceClient()
+	count := 0
+
+	List(client, ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNetworks(page)
+		if err != nil {
+			t.Errorf("Failed to extract networks: %v", err)
+			return false, err
+		}
+
+		expected := []Network{
+			Network{
+				Status:       "ACTIVE",
+				Subnets:      []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"},
+				Name:         "private-network",
+				AdminStateUp: true,
+				TenantID:     "4fd44f30292945e481c7b8a0c8908869",
+				Shared:       true,
+				ID:           "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+			},
+			Network{
+				Status:       "ACTIVE",
+				Subnets:      []string{"08eae331-0402-425a-923c-34f7cfe39c1b"},
+				Name:         "private",
+				AdminStateUp: true,
+				TenantID:     "26a7980765d0414dbc1fc1f88cdb7e6e",
+				Shared:       true,
+				ID:           "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [
+            "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        ],
+        "name": "private-network",
+        "admin_state_up": true,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "shared": true,
+        "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22"
+    }
+}
+			`)
+	})
+
+	n, err := Get(fake.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"})
+	th.AssertEquals(t, n.Name, "private-network")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertEquals(t, n.Shared, true)
+	th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "network": {
+        "name": "sample_network",
+        "admin_state_up": true
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [],
+        "name": "net1",
+        "admin_state_up": true,
+        "tenant_id": "9bacb3c5d39d41a79512987f338cf177",
+        "shared": false,
+        "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+    }
+}
+		`)
+	})
+
+	iTrue := true
+	options := CreateOpts{Name: "sample_network", AdminStateUp: &iTrue}
+	n, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertDeepEquals(t, n.Subnets, []string{})
+	th.AssertEquals(t, n.Name, "net1")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.TenantID, "9bacb3c5d39d41a79512987f338cf177")
+	th.AssertEquals(t, n.Shared, false)
+	th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestCreateWithOptionalFields(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+	"network": {
+			"name": "sample_network",
+			"admin_state_up": true,
+			"shared": true,
+			"tenant_id": "12345"
+	}
+}
+		`)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{}`)
+	})
+
+	iTrue := true
+	options := CreateOpts{Name: "sample_network", AdminStateUp: &iTrue, Shared: &iTrue, TenantID: "12345"}
+	_, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"network": {
+				"name": "new_network_name",
+				"admin_state_up": false,
+				"shared": true
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "network": {
+        "status": "ACTIVE",
+        "subnets": [],
+        "name": "new_network_name",
+        "admin_state_up": false,
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "shared": true,
+        "id": "4e8e5957-649f-477b-9e5b-f1f75b21c03c"
+    }
+}
+		`)
+	})
+
+	iTrue, iFalse := true, false
+	options := UpdateOpts{Name: "new_network_name", AdminStateUp: &iFalse, Shared: &iTrue}
+	n, err := Update(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Name, "new_network_name")
+	th.AssertEquals(t, n.AdminStateUp, false)
+	th.AssertEquals(t, n.Shared, true)
+	th.AssertEquals(t, n.ID, "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/networks/4e8e5957-649f-477b-9e5b-f1f75b21c03c", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "4e8e5957-649f-477b-9e5b-f1f75b21c03c")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/networks/results.go b/openstack/networking/v2/networks/results.go
new file mode 100644
index 0000000..d928980
--- /dev/null
+++ b/openstack/networking/v2/networks/results.go
@@ -0,0 +1,101 @@
+package networks
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a network resource.
+func (r commonResult) Extract() (*Network, error) {
+	var s struct {
+		Network *Network `json:"network"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Network, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// Network represents, well, a network.
+type Network struct {
+	// UUID for the network
+	ID string `json:"id"`
+
+	// Human-readable name for the network. Might not be unique.
+	Name string `json:"name"`
+
+	// The administrative state of network. If false (down), the network does not forward packets.
+	AdminStateUp bool `json:"admin_state_up"`
+
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `json:"status"`
+
+	// Subnets associated with this network.
+	Subnets []string `json:"subnets"`
+
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `json:"tenant_id"`
+
+	// Specifies whether the network resource can be accessed by any tenant or not.
+	Shared bool `json:"shared"`
+}
+
+// NetworkPage is the page returned by a pager when traversing over a
+// collection of networks.
+type NetworkPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of networks has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r NetworkPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"networks_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a NetworkPage struct is empty.
+func (r NetworkPage) IsEmpty() (bool, error) {
+	is, err := ExtractNetworks(r)
+	return len(is) == 0, err
+}
+
+// ExtractNetworks accepts a Page struct, specifically a NetworkPage struct,
+// and extracts the elements into a slice of Network structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractNetworks(r pagination.Page) ([]Network, error) {
+	var s struct {
+		Networks []Network `json:"networks"`
+	}
+	err := (r.(NetworkPage)).ExtractInto(&s)
+	return s.Networks, err
+}
diff --git a/openstack/networking/v2/networks/urls.go b/openstack/networking/v2/networks/urls.go
new file mode 100644
index 0000000..4a8fb1d
--- /dev/null
+++ b/openstack/networking/v2/networks/urls.go
@@ -0,0 +1,31 @@
+package networks
+
+import "github.com/gophercloud/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("networks", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("networks")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/ports/doc.go b/openstack/networking/v2/ports/doc.go
new file mode 100644
index 0000000..f16a4bb
--- /dev/null
+++ b/openstack/networking/v2/ports/doc.go
@@ -0,0 +1,8 @@
+// Package ports contains functionality for working with Neutron port resources.
+// A port represents a virtual switch port on a logical network switch. Virtual
+// instances attach their interfaces into ports. The logical port also defines
+// the MAC address and the IP address(es) to be assigned to the interfaces
+// plugged into them. When IP addresses are associated to a port, this also
+// implies the port is associated with a subnet, as the IP address was taken
+// from the allocation pool for a specific subnet.
+package ports
diff --git a/openstack/networking/v2/ports/requests.go b/openstack/networking/v2/ports/requests.go
new file mode 100644
index 0000000..2a53202
--- /dev/null
+++ b/openstack/networking/v2/ports/requests.go
@@ -0,0 +1,180 @@
+package ports
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToPortListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the port attributes you want to see returned. SortKey allows you to sort
+// by a particular port attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status       string `q:"status"`
+	Name         string `q:"name"`
+	AdminStateUp *bool  `q:"admin_state_up"`
+	NetworkID    string `q:"network_id"`
+	TenantID     string `q:"tenant_id"`
+	DeviceOwner  string `q:"device_owner"`
+	MACAddress   string `q:"mac_address"`
+	ID           string `q:"id"`
+	DeviceID     string `q:"device_id"`
+	Limit        int    `q:"limit"`
+	Marker       string `q:"marker"`
+	SortKey      string `q:"sort_key"`
+	SortDir      string `q:"sort_dir"`
+}
+
+// ToPortListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToPortListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// ports. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those ports that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToPortListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return PortPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+	return
+}
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToPortCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts represents the attributes used when creating a new port.
+type CreateOpts struct {
+	NetworkID           string        `json:"network_id" required:"true"`
+	Name                string        `json:"name,omitempty"`
+	AdminStateUp        *bool         `json:"admin_state_up,omitempty"`
+	MACAddress          string        `json:"mac_address,omitempty"`
+	FixedIPs            interface{}   `json:"fixed_ips,omitempty"`
+	DeviceID            string        `json:"device_id,omitempty"`
+	DeviceOwner         string        `json:"device_owner,omitempty"`
+	TenantID            string        `json:"tenant_id,omitempty"`
+	SecurityGroups      []string      `json:"security_groups,omitempty"`
+	AllowedAddressPairs []AddressPair `json:"allowed_address_pairs,omitempty"`
+}
+
+// ToPortCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPortCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "port")
+}
+
+// Create accepts a CreateOpts struct and creates a new network using the values
+// provided. You must remember to provide a NetworkID value.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToPortCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Update operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type UpdateOptsBuilder interface {
+	ToPortUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represents the attributes used when updating an existing port.
+type UpdateOpts struct {
+	Name                string        `json:"name,omitempty"`
+	AdminStateUp        *bool         `json:"admin_state_up,omitempty"`
+	FixedIPs            interface{}   `json:"fixed_ips,omitempty"`
+	DeviceID            string        `json:"device_id,omitempty"`
+	DeviceOwner         string        `json:"device_owner,omitempty"`
+	SecurityGroups      []string      `json:"security_groups,omitempty"`
+	AllowedAddressPairs []AddressPair `json:"allowed_address_pairs,omitempty"`
+}
+
+// ToPortUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPortUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "port")
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToPortUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete accepts a unique ID and deletes the port associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(deleteURL(c, id), nil)
+	return
+}
+
+// IDFromName is a convenience function that returns a port's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractPorts(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "port"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "port"}
+	}
+}
diff --git a/openstack/networking/v2/ports/requests_test.go b/openstack/networking/v2/ports/requests_test.go
new file mode 100644
index 0000000..bcd69d4
--- /dev/null
+++ b/openstack/networking/v2/ports/requests_test.go
@@ -0,0 +1,356 @@
+package ports
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "ports": [
+        {
+            "status": "ACTIVE",
+            "binding:host_id": "devstack",
+            "name": "",
+            "admin_state_up": true,
+            "network_id": "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+            "tenant_id": "",
+            "device_owner": "network:router_gateway",
+            "mac_address": "fa:16:3e:58:42:ed",
+            "fixed_ips": [
+                {
+                    "subnet_id": "008ba151-0b8c-4a67-98b5-0d2b87666062",
+                    "ip_address": "172.24.4.2"
+                }
+            ],
+            "id": "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+            "security_groups": [],
+            "device_id": "9ae135f4-b6e0-4dad-9e91-3c223e385824"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractPorts(page)
+		if err != nil {
+			t.Errorf("Failed to extract subnets: %v", err)
+			return false, nil
+		}
+
+		expected := []Port{
+			Port{
+				Status:       "ACTIVE",
+				Name:         "",
+				AdminStateUp: true,
+				NetworkID:    "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+				TenantID:     "",
+				DeviceOwner:  "network:router_gateway",
+				MACAddress:   "fa:16:3e:58:42:ed",
+				FixedIPs: []IP{
+					IP{
+						SubnetID:  "008ba151-0b8c-4a67-98b5-0d2b87666062",
+						IPAddress: "172.24.4.2",
+					},
+				},
+				ID:             "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+				SecurityGroups: []string{},
+				DeviceID:       "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "ACTIVE",
+        "name": "",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "7e02058126cc4950b75f9970368ba177",
+        "device_owner": "network:router_interface",
+        "mac_address": "fa:16:3e:23:fd:d7",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.1"
+            }
+        ],
+        "id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
+        "security_groups": [],
+        "device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e"
+    }
+}
+			`)
+	})
+
+	n, err := Get(fake.ServiceClient(), "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertEquals(t, n.Name, "")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "7e02058126cc4950b75f9970368ba177")
+	th.AssertEquals(t, n.DeviceOwner, "network:router_interface")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:23:fd:d7")
+	th.AssertDeepEquals(t, n.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.1"},
+	})
+	th.AssertEquals(t, n.ID, "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{})
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertEquals(t, n.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "port": {
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "name": "private-port",
+        "admin_state_up": true,
+				"fixed_ips": [
+						{
+								"subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+								"ip_address": "10.0.0.2"
+						}
+				],
+				"security_groups": ["foo"],
+        "allowed_address_pairs": [
+          {
+            "ip_address": "10.0.0.4",
+            "mac_address": "fa:16:3e:c9:cb:f0"
+          }
+        ]
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "private-port",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.2"
+            }
+        ],
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "allowed_address_pairs": [
+          {
+            "ip_address": "10.0.0.4",
+            "mac_address": "fa:16:3e:c9:cb:f0"
+          }
+        ],
+        "device_id": ""
+    }
+}
+		`)
+	})
+
+	asu := true
+	options := CreateOpts{
+		Name:         "private-port",
+		AdminStateUp: &asu,
+		NetworkID:    "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+		FixedIPs: []IP{
+			IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+		},
+		SecurityGroups: []string{"foo"},
+		AllowedAddressPairs: []AddressPair{
+			AddressPair{IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+		},
+	}
+	n, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "DOWN")
+	th.AssertEquals(t, n.Name, "private-port")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "d6700c0c9ffa4f1cb322cd4a1f3906fa")
+	th.AssertEquals(t, n.DeviceOwner, "")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:c9:cb:f0")
+	th.AssertDeepEquals(t, n.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+	})
+	th.AssertEquals(t, n.ID, "65c0ee9f-d634-4522-8954-51021b570b0d")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+	th.AssertDeepEquals(t, n.AllowedAddressPairs, []AddressPair{
+		AddressPair{IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+	})
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"port": {
+				"name": "new_port_name",
+				"fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.3"
+            }
+        ],
+        "allowed_address_pairs": [
+          {
+            "ip_address": "10.0.0.4",
+            "mac_address": "fa:16:3e:c9:cb:f0"
+          }
+        ],
+				"security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ]
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "new_port_name",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.3"
+            }
+        ],
+        "allowed_address_pairs": [
+          {
+            "ip_address": "10.0.0.4",
+            "mac_address": "fa:16:3e:c9:cb:f0"
+          }
+        ],
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "device_id": ""
+    }
+}
+		`)
+	})
+
+	options := UpdateOpts{
+		Name: "new_port_name",
+		FixedIPs: []IP{
+			IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+		},
+		SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+		AllowedAddressPairs: []AddressPair{
+			AddressPair{IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+		},
+	}
+
+	s, err := Update(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "new_port_name")
+	th.AssertDeepEquals(t, s.FixedIPs, []IP{
+		IP{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+	})
+	th.AssertDeepEquals(t, s.AllowedAddressPairs, []AddressPair{
+		AddressPair{IPAddress: "10.0.0.4", MACAddress: "fa:16:3e:c9:cb:f0"},
+	})
+	th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/ports/results.go b/openstack/networking/v2/ports/results.go
new file mode 100644
index 0000000..57a1765
--- /dev/null
+++ b/openstack/networking/v2/ports/results.go
@@ -0,0 +1,119 @@
+package ports
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a port resource.
+func (r commonResult) Extract() (*Port, error) {
+	var s struct {
+		Port *Port `json:"port"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Port, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// IP is a sub-struct that represents an individual IP.
+type IP struct {
+	SubnetID  string `json:"subnet_id"`
+	IPAddress string `json:"ip_address,omitempty"`
+}
+
+// AddressPair contains the IP Address and the MAC address.
+type AddressPair struct {
+	IPAddress  string `json:"ip_address,omitempty"`
+	MACAddress string `json:"mac_address,omitempty"`
+}
+
+// Port represents a Neutron port. See package documentation for a top-level
+// description of what this is.
+type Port struct {
+	// UUID for the port.
+	ID string `json:"id"`
+	// Network that this port is associated with.
+	NetworkID string `json:"network_id"`
+	// Human-readable name for the port. Might not be unique.
+	Name string `json:"name"`
+	// Administrative state of port. If false (down), port does not forward packets.
+	AdminStateUp bool `json:"admin_state_up"`
+	// Indicates whether network is currently operational. Possible values include
+	// `ACTIVE', `DOWN', `BUILD', or `ERROR'. Plug-ins might define additional values.
+	Status string `json:"status"`
+	// Mac address to use on this port.
+	MACAddress string `json:"mac_address"`
+	// Specifies IP addresses for the port thus associating the port itself with
+	// the subnets where the IP addresses are picked from
+	FixedIPs []IP `json:"fixed_ips"`
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `json:"tenant_id"`
+	// Identifies the entity (e.g.: dhcp agent) using this port.
+	DeviceOwner string `json:"device_owner"`
+	// Specifies the IDs of any security groups associated with a port.
+	SecurityGroups []string `json:"security_groups"`
+	// Identifies the device (e.g., virtual server) using this port.
+	DeviceID string `json:"device_id"`
+	// Identifies the list of IP addresses the port will recognize/accept
+	AllowedAddressPairs []AddressPair `json:"allowed_address_pairs"`
+}
+
+// PortPage is the page returned by a pager when traversing over a collection
+// of network ports.
+type PortPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of ports has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r PortPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"ports_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a PortPage struct is empty.
+func (r PortPage) IsEmpty() (bool, error) {
+	is, err := ExtractPorts(r)
+	return len(is) == 0, err
+}
+
+// ExtractPorts accepts a Page struct, specifically a PortPage struct,
+// and extracts the elements into a slice of Port structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPorts(r pagination.Page) ([]Port, error) {
+	var s struct {
+		Ports []Port `json:"ports"`
+	}
+	err := (r.(PortPage)).ExtractInto(&s)
+	return s.Ports, err
+}
diff --git a/openstack/networking/v2/ports/urls.go b/openstack/networking/v2/ports/urls.go
new file mode 100644
index 0000000..600d6f2
--- /dev/null
+++ b/openstack/networking/v2/ports/urls.go
@@ -0,0 +1,31 @@
+package ports
+
+import "github.com/gophercloud/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("ports", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("ports")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/subnets/doc.go b/openstack/networking/v2/subnets/doc.go
new file mode 100644
index 0000000..43e8296
--- /dev/null
+++ b/openstack/networking/v2/subnets/doc.go
@@ -0,0 +1,10 @@
+// Package subnets contains functionality for working with Neutron subnet
+// resources. A subnet represents an IP address block that can be used to
+// assign IP addresses to virtual instances. Each subnet must have a CIDR and
+// must be associated with a network. IPs can either be selected from the whole
+// subnet CIDR or from allocation pools specified by the user.
+//
+// A subnet can also have a gateway, a list of DNS name servers, and host routes.
+// This information is pushed to instances whose interfaces are associated with
+// the subnet.
+package subnets
diff --git a/openstack/networking/v2/subnets/errors.go b/openstack/networking/v2/subnets/errors.go
new file mode 100644
index 0000000..0db0a6e
--- /dev/null
+++ b/openstack/networking/v2/subnets/errors.go
@@ -0,0 +1,13 @@
+package subnets
+
+import "fmt"
+
+func err(str string) error {
+	return fmt.Errorf("%s", str)
+}
+
+var (
+	errNetworkIDRequired = err("A network ID is required")
+	errCIDRRequired      = err("A valid CIDR is required")
+	errInvalidIPType     = err("An IP type must either be 4 or 6")
+)
diff --git a/openstack/networking/v2/subnets/requests.go b/openstack/networking/v2/subnets/requests.go
new file mode 100644
index 0000000..2706a5e
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests.go
@@ -0,0 +1,185 @@
+package subnets
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToSubnetListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the subnet attributes you want to see returned. SortKey allows you to sort
+// by a particular subnet attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Name       string `q:"name"`
+	EnableDHCP *bool  `q:"enable_dhcp"`
+	NetworkID  string `q:"network_id"`
+	TenantID   string `q:"tenant_id"`
+	IPVersion  int    `q:"ip_version"`
+	GatewayIP  string `q:"gateway_ip"`
+	CIDR       string `q:"cidr"`
+	ID         string `q:"id"`
+	Limit      int    `q:"limit"`
+	Marker     string `q:"marker"`
+	SortKey    string `q:"sort_key"`
+	SortDir    string `q:"sort_dir"`
+}
+
+// ToSubnetListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToSubnetListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// subnets. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+//
+// Default policy settings return only those subnets that are owned by the tenant
+// who submits the request, unless the request is submitted by a user with
+// administrative rights.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToSubnetListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		return SubnetPage{pagination.LinkedPageBase{PageResult: r}}
+	})
+}
+
+// Get retrieves a specific subnet based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, id), &r.Body, nil)
+	return
+}
+
+// IPVersion is the IP address version for the subnet. Valid instances are
+// 4 and 6
+type IPVersion int
+
+// Valid IP types
+const (
+	IPv4 IPVersion = 4
+	IPv6 IPVersion = 6
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToSubnetCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts represents the attributes used when creating a new subnet.
+type CreateOpts struct {
+	NetworkID       string           `json:"network_id" required:"true"`
+	CIDR            string           `json:"cidr" required:"true"`
+	Name            string           `json:"name,omitempty"`
+	TenantID        string           `json:"tenant_id,omitempty"`
+	AllocationPools []AllocationPool `json:"allocation_pools,omitempty"`
+	GatewayIP       string           `json:"gateway_ip,omitempty"`
+	IPVersion       IPVersion        `json:"ip_version,omitempty"`
+	EnableDHCP      *bool            `json:"enable_dhcp,omitempty"`
+	DNSNameservers  []string         `json:"dns_nameservers,omitempty"`
+	HostRoutes      []HostRoute      `json:"host_routes,omitempty"`
+}
+
+// ToSubnetCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToSubnetCreateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "subnet")
+}
+
+// Create accepts a CreateOpts struct and creates a new subnet using the values
+// provided. You must remember to provide a valid NetworkID, CIDR and IP version.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToSubnetCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+	ToSubnetUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts represents the attributes used when updating an existing subnet.
+type UpdateOpts struct {
+	Name           string      `json:"name,omitempty"`
+	GatewayIP      string      `json:"gateway_ip,omitempty"`
+	DNSNameservers []string    `json:"dns_nameservers,omitempty"`
+	HostRoutes     []HostRoute `json:"host_routes,omitempty"`
+	EnableDHCP     *bool       `json:"enable_dhcp,omitempty"`
+}
+
+// ToSubnetUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToSubnetUpdateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "subnet")
+}
+
+// Update accepts a UpdateOpts struct and updates an existing subnet using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToSubnetUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(updateURL(c, id), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return
+}
+
+// Delete accepts a unique ID and deletes the subnet associated with it.
+func Delete(c *gophercloud.ServiceClient, id string) (r DeleteResult) {
+	_, r.Err = c.Delete(deleteURL(c, id), nil)
+	return
+}
+
+// IDFromName is a convenience function that returns a subnet's ID given its name.
+func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) {
+	count := 0
+	id := ""
+	pages, err := List(client, nil).AllPages()
+	if err != nil {
+		return "", err
+	}
+
+	all, err := ExtractSubnets(pages)
+	if err != nil {
+		return "", err
+	}
+
+	for _, s := range all {
+		if s.Name == name {
+			count++
+			id = s.ID
+		}
+	}
+
+	switch count {
+	case 0:
+		return "", gophercloud.ErrResourceNotFound{Name: name, ResourceType: "subnet"}
+	case 1:
+		return id, nil
+	default:
+		return "", gophercloud.ErrMultipleResourcesFound{Name: name, Count: count, ResourceType: "subnet"}
+	}
+}
diff --git a/openstack/networking/v2/subnets/requests_test.go b/openstack/networking/v2/subnets/requests_test.go
new file mode 100644
index 0000000..5178c90
--- /dev/null
+++ b/openstack/networking/v2/subnets/requests_test.go
@@ -0,0 +1,362 @@
+package subnets
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/gophercloud/gophercloud/openstack/networking/v2/common"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "subnets": [
+        {
+            "name": "private-subnet",
+            "enable_dhcp": true,
+            "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+            "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+            "dns_nameservers": [],
+            "allocation_pools": [
+                {
+                    "start": "10.0.0.2",
+                    "end": "10.0.0.254"
+                }
+            ],
+            "host_routes": [],
+            "ip_version": 4,
+            "gateway_ip": "10.0.0.1",
+            "cidr": "10.0.0.0/24",
+            "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+        },
+        {
+            "name": "my_subnet",
+            "enable_dhcp": true,
+            "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+            "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+            "dns_nameservers": [],
+            "allocation_pools": [
+                {
+                    "start": "192.0.0.2",
+                    "end": "192.255.255.254"
+                }
+            ],
+            "host_routes": [],
+            "ip_version": 4,
+            "gateway_ip": "192.0.0.1",
+            "cidr": "192.0.0.0/8",
+            "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+        }
+    ]
+}
+      `)
+	})
+
+	count := 0
+
+	List(fake.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractSubnets(page)
+		if err != nil {
+			t.Errorf("Failed to extract subnets: %v", err)
+			return false, nil
+		}
+
+		expected := []Subnet{
+			Subnet{
+				Name:           "private-subnet",
+				EnableDHCP:     true,
+				NetworkID:      "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+				TenantID:       "26a7980765d0414dbc1fc1f88cdb7e6e",
+				DNSNameservers: []string{},
+				AllocationPools: []AllocationPool{
+					AllocationPool{
+						Start: "10.0.0.2",
+						End:   "10.0.0.254",
+					},
+				},
+				HostRoutes: []HostRoute{},
+				IPVersion:  4,
+				GatewayIP:  "10.0.0.1",
+				CIDR:       "10.0.0.0/24",
+				ID:         "08eae331-0402-425a-923c-34f7cfe39c1b",
+			},
+			Subnet{
+				Name:           "my_subnet",
+				EnableDHCP:     true,
+				NetworkID:      "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+				TenantID:       "4fd44f30292945e481c7b8a0c8908869",
+				DNSNameservers: []string{},
+				AllocationPools: []AllocationPool{
+					AllocationPool{
+						Start: "192.0.0.2",
+						End:   "192.255.255.254",
+					},
+				},
+				HostRoutes: []HostRoute{},
+				IPVersion:  4,
+				GatewayIP:  "192.0.0.1",
+				CIDR:       "192.0.0.0/8",
+				ID:         "54d6f61d-db07-451c-9ab3-b9609b6b6f0b",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/54d6f61d-db07-451c-9ab3-b9609b6b6f0b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "my_subnet",
+        "enable_dhcp": true,
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "192.0.0.2",
+                "end": "192.255.255.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "192.0.0.1",
+        "cidr": "192.0.0.0/8",
+        "id": "54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
+    }
+}
+			`)
+	})
+
+	s, err := Get(fake.ServiceClient(), "54d6f61d-db07-451c-9ab3-b9609b6b6f0b").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "my_subnet")
+	th.AssertEquals(t, s.EnableDHCP, true)
+	th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+	th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+		AllocationPool{
+			Start: "192.0.0.2",
+			End:   "192.255.255.254",
+		},
+	})
+	th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.GatewayIP, "192.0.0.1")
+	th.AssertEquals(t, s.CIDR, "192.0.0.0/8")
+	th.AssertEquals(t, s.ID, "54d6f61d-db07-451c-9ab3-b9609b6b6f0b")
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet": {
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "ip_version": 4,
+        "cidr": "192.168.199.0/24",
+				"dns_nameservers": ["foo"],
+				"allocation_pools": [
+						{
+								"start": "192.168.199.2",
+								"end": "192.168.199.254"
+						}
+				],
+				"host_routes": [{"destination":"","nexthop": "bar"}]
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "",
+        "enable_dhcp": true,
+        "network_id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+        "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "192.168.199.2",
+                "end": "192.168.199.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "192.168.199.1",
+        "cidr": "192.168.199.0/24",
+        "id": "3b80198d-4f7b-4f77-9ef5-774d54e17126"
+    }
+}
+		`)
+	})
+
+	opts := CreateOpts{
+		NetworkID: "d32019d3-bc6e-4319-9c1d-6722fc136a22",
+		IPVersion: 4,
+		CIDR:      "192.168.199.0/24",
+		AllocationPools: []AllocationPool{
+			AllocationPool{
+				Start: "192.168.199.2",
+				End:   "192.168.199.254",
+			},
+		},
+		DNSNameservers: []string{"foo"},
+		HostRoutes: []HostRoute{
+			HostRoute{NextHop: "bar"},
+		},
+	}
+	s, err := Create(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "")
+	th.AssertEquals(t, s.EnableDHCP, true)
+	th.AssertEquals(t, s.NetworkID, "d32019d3-bc6e-4319-9c1d-6722fc136a22")
+	th.AssertEquals(t, s.TenantID, "4fd44f30292945e481c7b8a0c8908869")
+	th.AssertDeepEquals(t, s.DNSNameservers, []string{})
+	th.AssertDeepEquals(t, s.AllocationPools, []AllocationPool{
+		AllocationPool{
+			Start: "192.168.199.2",
+			End:   "192.168.199.254",
+		},
+	})
+	th.AssertDeepEquals(t, s.HostRoutes, []HostRoute{})
+	th.AssertEquals(t, s.IPVersion, 4)
+	th.AssertEquals(t, s.GatewayIP, "192.168.199.1")
+	th.AssertEquals(t, s.CIDR, "192.168.199.0/24")
+	th.AssertEquals(t, s.ID, "3b80198d-4f7b-4f77-9ef5-774d54e17126")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+
+	res = Create(fake.ServiceClient(), CreateOpts{NetworkID: "foo"})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+
+	res = Create(fake.ServiceClient(), CreateOpts{NetworkID: "foo", CIDR: "bar", IPVersion: 40})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+				"dns_nameservers": ["foo"],
+				"host_routes": [{"destination":"","nexthop": "bar"}]
+    }
+}
+		`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "subnet": {
+        "name": "my_new_subnet",
+        "enable_dhcp": true,
+        "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
+        "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
+        "dns_nameservers": [],
+        "allocation_pools": [
+            {
+                "start": "10.0.0.2",
+                "end": "10.0.0.254"
+            }
+        ],
+        "host_routes": [],
+        "ip_version": 4,
+        "gateway_ip": "10.0.0.1",
+        "cidr": "10.0.0.0/24",
+        "id": "08eae331-0402-425a-923c-34f7cfe39c1b"
+    }
+}
+	`)
+	})
+
+	opts := UpdateOpts{
+		Name:           "my_new_subnet",
+		DNSNameservers: []string{"foo"},
+		HostRoutes: []HostRoute{
+			HostRoute{NextHop: "bar"},
+		},
+	}
+	s, err := Update(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b", opts).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "my_new_subnet")
+	th.AssertEquals(t, s.ID, "08eae331-0402-425a-923c-34f7cfe39c1b")
+}
+
+func TestDelete(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/v2.0/subnets/08eae331-0402-425a-923c-34f7cfe39c1b", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	res := Delete(fake.ServiceClient(), "08eae331-0402-425a-923c-34f7cfe39c1b")
+	th.AssertNoErr(t, res.Err)
+}
diff --git a/openstack/networking/v2/subnets/results.go b/openstack/networking/v2/subnets/results.go
new file mode 100644
index 0000000..ab5cce1
--- /dev/null
+++ b/openstack/networking/v2/subnets/results.go
@@ -0,0 +1,117 @@
+package subnets
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a subnet resource.
+func (r commonResult) Extract() (*Subnet, error) {
+	var s struct {
+		Subnet *Subnet `json:"subnet"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Subnet, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// AllocationPool represents a sub-range of cidr available for dynamic
+// allocation to ports, e.g. {Start: "10.0.0.2", End: "10.0.0.254"}
+type AllocationPool struct {
+	Start string `json:"start"`
+	End   string `json:"end"`
+}
+
+// HostRoute represents a route that should be used by devices with IPs from
+// a subnet (not including local subnet route).
+type HostRoute struct {
+	DestinationCIDR string `json:"destination"`
+	NextHop         string `json:"nexthop"`
+}
+
+// Subnet represents a subnet. See package documentation for a top-level
+// description of what this is.
+type Subnet struct {
+	// UUID representing the subnet
+	ID string `json:"id"`
+	// UUID of the parent network
+	NetworkID string `json:"network_id"`
+	// Human-readable name for the subnet. Might not be unique.
+	Name string `json:"name"`
+	// IP version, either `4' or `6'
+	IPVersion int `json:"ip_version"`
+	// CIDR representing IP range for this subnet, based on IP version
+	CIDR string `json:"cidr"`
+	// Default gateway used by devices in this subnet
+	GatewayIP string `json:"gateway_ip"`
+	// DNS name servers used by hosts in this subnet.
+	DNSNameservers []string `json:"dns_nameservers"`
+	// Sub-ranges of CIDR available for dynamic allocation to ports. See AllocationPool.
+	AllocationPools []AllocationPool `json:"allocation_pools"`
+	// Routes that should be used by devices with IPs from this subnet (not including local subnet route).
+	HostRoutes []HostRoute `json:"host_routes"`
+	// Specifies whether DHCP is enabled for this subnet or not.
+	EnableDHCP bool `json:"enable_dhcp"`
+	// Owner of network. Only admin users can specify a tenant_id other than its own.
+	TenantID string `json:"tenant_id"`
+}
+
+// SubnetPage is the page returned by a pager when traversing over a collection
+// of subnets.
+type SubnetPage struct {
+	pagination.LinkedPageBase
+}
+
+// NextPageURL is invoked when a paginated collection of subnets has reached
+// the end of a page and the pager seeks to traverse over a new one. In order
+// to do this, it needs to construct the next page's URL.
+func (r SubnetPage) NextPageURL() (string, error) {
+	var s struct {
+		Links []gophercloud.Link `json:"subnets_links"`
+	}
+	err := r.ExtractInto(&s)
+	if err != nil {
+		return "", err
+	}
+	return gophercloud.ExtractNextURL(s.Links)
+}
+
+// IsEmpty checks whether a SubnetPage struct is empty.
+func (r SubnetPage) IsEmpty() (bool, error) {
+	is, err := ExtractSubnets(r)
+	return len(is) == 0, err
+}
+
+// ExtractSubnets accepts a Page struct, specifically a SubnetPage struct,
+// and extracts the elements into a slice of Subnet structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractSubnets(r pagination.Page) ([]Subnet, error) {
+	var s struct {
+		Subnets []Subnet `json:"subnets"`
+	}
+	err := (r.(SubnetPage)).ExtractInto(&s)
+	return s.Subnets, err
+}
diff --git a/openstack/networking/v2/subnets/results_test.go b/openstack/networking/v2/subnets/results_test.go
new file mode 100644
index 0000000..ce71a46
--- /dev/null
+++ b/openstack/networking/v2/subnets/results_test.go
@@ -0,0 +1,54 @@
+package subnets
+
+import (
+	"encoding/json"
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"testing"
+)
+
+func TestHostRoute(t *testing.T) {
+	sejson := []byte(`
+    {"subnet": {
+      "name": "test-subnet",
+      "enable_dhcp": false,
+      "network_id": "3e66c41e-cbbd-4019-9aab-740b7e4150a0",
+      "tenant_id": "f86e123198cf42d19c8854c5f80c2f06",
+      "dns_nameservers": [],
+      "gateway_ip": "172.16.0.1",
+      "ipv6_ra_mode": null,
+      "allocation_pools": [
+        {
+          "start": "172.16.0.2",
+          "end": "172.16.255.254"
+        }
+      ],
+      "host_routes": [
+        {
+          "destination": "172.20.1.0/24",
+		  "nexthop": "172.16.0.2"
+        }
+      ],
+      "ip_version": 4,
+      "ipv6_address_mode": null,
+      "cidr": "172.16.0.0/16",
+      "id": "6dcaa873-7115-41af-9ef5-915f73636e43",
+      "subnetpool_id": null
+  }}
+`)
+
+	var dejson interface{}
+	err := json.Unmarshal(sejson, &dejson)
+	if err != nil {
+		t.Fatalf("%s", err)
+	}
+
+	resp := commonResult{gophercloud.Result{Body: dejson}}
+	subnet, err := resp.Extract()
+	if err != nil {
+		t.Fatalf("%s", err)
+	}
+	route := subnet.HostRoutes[0]
+	th.AssertEquals(t, route.NextHop, "172.16.0.2")
+	th.AssertEquals(t, route.DestinationCIDR, "172.20.1.0/24")
+}
diff --git a/openstack/networking/v2/subnets/urls.go b/openstack/networking/v2/subnets/urls.go
new file mode 100644
index 0000000..7a4f2f7
--- /dev/null
+++ b/openstack/networking/v2/subnets/urls.go
@@ -0,0 +1,31 @@
+package subnets
+
+import "github.com/gophercloud/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("subnets", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("subnets")
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/objectstorage/v1/accounts/doc.go b/openstack/objectstorage/v1/accounts/doc.go
new file mode 100644
index 0000000..f5f894a
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/doc.go
@@ -0,0 +1,8 @@
+// Package accounts contains functionality for working with Object Storage
+// account resources. An account is the top-level resource the object storage
+// hierarchy: containers belong to accounts, objects belong to containers.
+//
+// Another way of thinking of an account is like a namespace for all your
+// resources. It is synonymous with a project or tenant in other OpenStack
+// services.
+package accounts
diff --git a/openstack/objectstorage/v1/accounts/fixtures.go b/openstack/objectstorage/v1/accounts/fixtures.go
new file mode 100644
index 0000000..16327e8
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/fixtures.go
@@ -0,0 +1,40 @@
+// +build fixtures
+
+package accounts
+
+import (
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleGetAccountSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetAccountSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "HEAD")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Set("X-Account-Container-Count", "2")
+		w.Header().Set("X-Account-Bytes-Used", "14")
+		w.Header().Set("X-Account-Meta-Subject", "books")
+		w.Header().Set("Date", "Fri, 17 Jan 2014 16:09:56 GMT")
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleUpdateAccountSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateAccountSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "X-Account-Meta-Gophercloud-Test", "accounts")
+
+		w.Header().Set("Date", "Fri, 17 Jan 2014 16:09:56 GMT")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/objectstorage/v1/accounts/requests.go b/openstack/objectstorage/v1/accounts/requests.go
new file mode 100644
index 0000000..b5beef2
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/requests.go
@@ -0,0 +1,100 @@
+package accounts
+
+import "github.com/gophercloud/gophercloud"
+
+// GetOptsBuilder allows extensions to add additional headers to the Get
+// request.
+type GetOptsBuilder interface {
+	ToAccountGetMap() (map[string]string, error)
+}
+
+// GetOpts is a structure that contains parameters for getting an account's
+// metadata.
+type GetOpts struct {
+	Newest bool `h:"X-Newest"`
+}
+
+// ToAccountGetMap formats a GetOpts into a map[string]string of headers.
+func (opts GetOpts) ToAccountGetMap() (map[string]string, error) {
+	return gophercloud.BuildHeaders(opts)
+}
+
+// Get is a function that retrieves an account's metadata. To extract just the
+// custom metadata, call the ExtractMetadata method on the GetResult. To extract
+// all the headers that are returned (including the metadata), call the
+// ExtractHeader method on the GetResult.
+func Get(c *gophercloud.ServiceClient, opts GetOptsBuilder) (r GetResult) {
+	h := make(map[string]string)
+	if opts != nil {
+		headers, err := opts.ToAccountGetMap()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		for k, v := range headers {
+			h[k] = v
+		}
+	}
+	resp, err := c.Request("HEAD", getURL(c), &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional headers to the Update
+// request.
+type UpdateOptsBuilder interface {
+	ToAccountUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that contains parameters for updating, creating, or
+// deleting an account's metadata.
+type UpdateOpts struct {
+	Metadata          map[string]string
+	ContentType       string `h:"Content-Type"`
+	DetectContentType bool   `h:"X-Detect-Content-Type"`
+	TempURLKey        string `h:"X-Account-Meta-Temp-URL-Key"`
+	TempURLKey2       string `h:"X-Account-Meta-Temp-URL-Key-2"`
+}
+
+// ToAccountUpdateMap formats an UpdateOpts into a map[string]string of headers.
+func (opts UpdateOpts) ToAccountUpdateMap() (map[string]string, error) {
+	headers, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range opts.Metadata {
+		headers["X-Account-Meta-"+k] = v
+	}
+	return headers, err
+}
+
+// Update is a function that creates, updates, or deletes an account's metadata.
+// To extract the headers returned, call the Extract method on the UpdateResult.
+func Update(c *gophercloud.ServiceClient, opts UpdateOptsBuilder) (r UpdateResult) {
+	h := make(map[string]string)
+	if opts != nil {
+		headers, err := opts.ToAccountUpdateMap()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		for k, v := range headers {
+			h[k] = v
+		}
+	}
+	resp, err := c.Request("POST", updateURL(c), &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{201, 202, 204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
diff --git a/openstack/objectstorage/v1/accounts/requests_test.go b/openstack/objectstorage/v1/accounts/requests_test.go
new file mode 100644
index 0000000..8aba591
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/requests_test.go
@@ -0,0 +1,32 @@
+package accounts
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestUpdateAccount(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleUpdateAccountSuccessfully(t)
+
+	options := &UpdateOpts{Metadata: map[string]string{"gophercloud-test": "accounts"}}
+	_, err := Update(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+}
+
+func TestGetAccount(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetAccountSuccessfully(t)
+
+	expectedMetadata := map[string]string{"Subject": "books"}
+	res := Get(fake.ServiceClient(), &GetOpts{})
+	th.AssertNoErr(t, res.Err)
+	actualMetadata, _ := res.ExtractMetadata()
+	th.CheckDeepEquals(t, expectedMetadata, actualMetadata)
+	_, err := res.Extract()
+	th.AssertNoErr(t, err)
+}
diff --git a/openstack/objectstorage/v1/accounts/results.go b/openstack/objectstorage/v1/accounts/results.go
new file mode 100644
index 0000000..f9e5fcd
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/results.go
@@ -0,0 +1,71 @@
+package accounts
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// UpdateResult is returned from a call to the Update function.
+type UpdateResult struct {
+	gophercloud.HeaderResult
+}
+
+// UpdateHeader represents the headers returned in the response from an Update request.
+type UpdateHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (ur UpdateResult) Extract() (*UpdateHeader, error) {
+	var uh *UpdateHeader
+	err := ur.ExtractInto(&uh)
+	return uh, err
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+	BytesUsed      string                  `json:"X-Account-Bytes-Used"`
+	ContainerCount string                  `json:"X-Account-Container-Count"`
+	ContentLength  string                  `json:"Content-Length"`
+	ContentType    string                  `json:"Content-Type"`
+	Date           gophercloud.JSONRFC1123 `json:"Date"`
+	ObjectCount    string                  `json:"X-Account-Object-Count"`
+	TransID        string                  `json:"X-Trans-Id"`
+	TempURLKey     string                  `json:"X-Account-Meta-Temp-URL-Key"`
+	TempURLKey2    string                  `json:"X-Account-Meta-Temp-URL-Key-2"`
+}
+
+// GetResult is returned from a call to the Get function.
+type GetResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (r GetResult) Extract() (*GetHeader, error) {
+	var s *GetHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metatdata associated with the account.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	metadata := make(map[string]string)
+	for k, v := range r.Header {
+		if strings.HasPrefix(k, "X-Account-Meta-") {
+			key := strings.TrimPrefix(k, "X-Account-Meta-")
+			metadata[key] = v[0]
+		}
+	}
+	return metadata, nil
+}
diff --git a/openstack/objectstorage/v1/accounts/urls.go b/openstack/objectstorage/v1/accounts/urls.go
new file mode 100644
index 0000000..71540b1
--- /dev/null
+++ b/openstack/objectstorage/v1/accounts/urls.go
@@ -0,0 +1,11 @@
+package accounts
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+	return c.Endpoint
+}
+
+func updateURL(c *gophercloud.ServiceClient) string {
+	return getURL(c)
+}
diff --git a/openstack/objectstorage/v1/containers/doc.go b/openstack/objectstorage/v1/containers/doc.go
new file mode 100644
index 0000000..5fed553
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/doc.go
@@ -0,0 +1,8 @@
+// Package containers contains functionality for working with Object Storage
+// container resources. A container serves as a logical namespace for objects
+// that are placed inside it - an object with the same name in two different
+// containers represents two different objects.
+//
+// In addition to containing objects, you can also use the container to control
+// access to objects by using an access control list (ACL).
+package containers
diff --git a/openstack/objectstorage/v1/containers/fixtures.go b/openstack/objectstorage/v1/containers/fixtures.go
new file mode 100644
index 0000000..fde8815
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/fixtures.go
@@ -0,0 +1,143 @@
+// +build fixtures
+
+package containers
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// ExpectedListInfo is the result expected from a call to `List` when full
+// info is requested.
+var ExpectedListInfo = []Container{
+	Container{
+		Count: 0,
+		Bytes: 0,
+		Name:  "janeausten",
+	},
+	Container{
+		Count: 1,
+		Bytes: 14,
+		Name:  "marktwain",
+	},
+}
+
+// ExpectedListNames is the result expected from a call to `List` when just
+// container names are requested.
+var ExpectedListNames = []string{"janeausten", "marktwain"}
+
+// HandleListContainerInfoSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `List` response when full info is requested.
+func HandleListContainerInfoSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, `[
+        {
+          "count": 0,
+          "bytes": 0,
+          "name": "janeausten"
+        },
+        {
+          "count": 1,
+          "bytes": 14,
+          "name": "marktwain"
+        }
+      ]`)
+		case "janeausten":
+			fmt.Fprintf(w, `[
+				{
+					"count": 1,
+					"bytes": 14,
+					"name": "marktwain"
+				}
+			]`)
+		case "marktwain":
+			fmt.Fprintf(w, `[]`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// HandleListContainerNamesSuccessfully creates an HTTP handler at `/` on the test handler mux that
+// responds with a `ListNames` response when only container names are requested.
+func HandleListContainerNamesSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "text/plain")
+
+		w.Header().Set("Content-Type", "text/plain")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, "janeausten\nmarktwain\n")
+		case "janeausten":
+			fmt.Fprintf(w, "marktwain\n")
+		case "marktwain":
+			fmt.Fprintf(w, ``)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// HandleCreateContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Create` response.
+func HandleCreateContainerSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Add("X-Container-Meta-Foo", "bar")
+		w.Header().Add("X-Trans-Id", "1234567")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleDeleteContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Delete` response.
+func HandleDeleteContainerSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleUpdateContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateContainerSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleGetContainerSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetContainerSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "HEAD")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/objectstorage/v1/containers/requests.go b/openstack/objectstorage/v1/containers/requests.go
new file mode 100644
index 0000000..a668673
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/requests.go
@@ -0,0 +1,191 @@
+package containers
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+	ToContainerListParams() (bool, string, error)
+}
+
+// ListOpts is a structure that holds options for listing containers.
+type ListOpts struct {
+	Full      bool
+	Limit     int    `q:"limit"`
+	Marker    string `q:"marker"`
+	EndMarker string `q:"end_marker"`
+	Format    string `q:"format"`
+	Prefix    string `q:"prefix"`
+	Delimiter string `q:"delimiter"`
+}
+
+// ToContainerListParams formats a ListOpts into a query string and boolean
+// representing whether to list complete information for each container.
+func (opts ListOpts) ToContainerListParams() (bool, string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return opts.Full, q.String(), err
+}
+
+// List is a function that retrieves containers associated with the account as
+// well as account metadata. It returns a pager which can be iterated with the
+// EachPage function.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	headers := map[string]string{"Accept": "text/plain", "Content-Type": "text/plain"}
+
+	url := listURL(c)
+	if opts != nil {
+		full, query, err := opts.ToContainerListParams()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+
+		if full {
+			headers = map[string]string{"Accept": "application/json", "Content-Type": "application/json"}
+		}
+	}
+
+	pager := pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		p := ContainerPage{pagination.MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	})
+	pager.Headers = headers
+	return pager
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToContainerCreateMap() (map[string]string, error)
+}
+
+// CreateOpts is a structure that holds parameters for creating a container.
+type CreateOpts struct {
+	Metadata          map[string]string
+	ContainerRead     string `h:"X-Container-Read"`
+	ContainerSyncTo   string `h:"X-Container-Sync-To"`
+	ContainerSyncKey  string `h:"X-Container-Sync-Key"`
+	ContainerWrite    string `h:"X-Container-Write"`
+	ContentType       string `h:"Content-Type"`
+	DetectContentType bool   `h:"X-Detect-Content-Type"`
+	IfNoneMatch       string `h:"If-None-Match"`
+	VersionsLocation  string `h:"X-Versions-Location"`
+}
+
+// ToContainerCreateMap formats a CreateOpts into a map of headers.
+func (opts CreateOpts) ToContainerCreateMap() (map[string]string, error) {
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range opts.Metadata {
+		h["X-Container-Meta-"+k] = v
+	}
+	return h, nil
+}
+
+// Create is a function that creates a new container.
+func Create(c *gophercloud.ServiceClient, containerName string, opts CreateOptsBuilder) (r CreateResult) {
+	h := make(map[string]string)
+	if opts != nil {
+		headers, err := opts.ToContainerCreateMap()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		for k, v := range headers {
+			h[k] = v
+		}
+	}
+	resp, err := c.Request("PUT", createURL(c, containerName), &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{201, 202, 204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// Delete is a function that deletes a container.
+func Delete(c *gophercloud.ServiceClient, containerName string) (r DeleteResult) {
+	_, r.Err = c.Delete(deleteURL(c, containerName), nil)
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+	ToContainerUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that holds parameters for updating, creating, or
+// deleting a container's metadata.
+type UpdateOpts struct {
+	Metadata               map[string]string
+	ContainerRead          string `h:"X-Container-Read"`
+	ContainerSyncTo        string `h:"X-Container-Sync-To"`
+	ContainerSyncKey       string `h:"X-Container-Sync-Key"`
+	ContainerWrite         string `h:"X-Container-Write"`
+	ContentType            string `h:"Content-Type"`
+	DetectContentType      bool   `h:"X-Detect-Content-Type"`
+	RemoveVersionsLocation string `h:"X-Remove-Versions-Location"`
+	VersionsLocation       string `h:"X-Versions-Location"`
+}
+
+// ToContainerUpdateMap formats a CreateOpts into a map of headers.
+func (opts UpdateOpts) ToContainerUpdateMap() (map[string]string, error) {
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range opts.Metadata {
+		h["X-Container-Meta-"+k] = v
+	}
+	return h, nil
+}
+
+// Update is a function that creates, updates, or deletes a container's
+// metadata.
+func Update(c *gophercloud.ServiceClient, containerName string, opts UpdateOptsBuilder) (r UpdateResult) {
+	h := make(map[string]string)
+	if opts != nil {
+		headers, err := opts.ToContainerUpdateMap()
+		if err != nil {
+			r.Err = err
+			return
+		}
+
+		for k, v := range headers {
+			h[k] = v
+		}
+	}
+	resp, err := c.Request("POST", updateURL(c, containerName), &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{201, 202, 204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// Get is a function that retrieves the metadata of a container. To extract just
+// the custom metadata, pass the GetResult response to the ExtractMetadata
+// function.
+func Get(c *gophercloud.ServiceClient, containerName string) (r GetResult) {
+	resp, err := c.Request("HEAD", getURL(c, containerName), &gophercloud.RequestOpts{
+		OkCodes: []int{200, 204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
diff --git a/openstack/objectstorage/v1/containers/requests_test.go b/openstack/objectstorage/v1/containers/requests_test.go
new file mode 100644
index 0000000..5066ab2
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/requests_test.go
@@ -0,0 +1,117 @@
+package containers
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+var metadata = map[string]string{"gophercloud-test": "containers"}
+
+func TestListContainerInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListContainerInfoSuccessfully(t)
+
+	count := 0
+	err := List(fake.ServiceClient(), &ListOpts{Full: true}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractInfo(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ExpectedListInfo, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestListAllContainerInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListContainerInfoSuccessfully(t)
+
+	allPages, err := List(fake.ServiceClient(), &ListOpts{Full: true}).AllPages()
+	th.AssertNoErr(t, err)
+	actual, err := ExtractInfo(allPages)
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedListInfo, actual)
+}
+
+func TestListContainerNames(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListContainerNamesSuccessfully(t)
+
+	count := 0
+	err := List(fake.ServiceClient(), &ListOpts{Full: false}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNames(page)
+		if err != nil {
+			t.Errorf("Failed to extract container names: %v", err)
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, ExpectedListNames, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestListAllContainerNames(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListContainerNamesSuccessfully(t)
+
+	allPages, err := List(fake.ServiceClient(), &ListOpts{Full: false}).AllPages()
+	th.AssertNoErr(t, err)
+	actual, err := ExtractNames(allPages)
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, ExpectedListNames, actual)
+}
+
+func TestCreateContainer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateContainerSuccessfully(t)
+
+	options := CreateOpts{ContentType: "application/json", Metadata: map[string]string{"foo": "bar"}}
+	res := Create(fake.ServiceClient(), "testContainer", options)
+	c, err := res.Extract()
+	th.CheckNoErr(t, err)
+	th.CheckEquals(t, "bar", res.Header["X-Container-Meta-Foo"][0])
+	th.CheckEquals(t, "1234567", c.TransID)
+}
+
+func TestDeleteContainer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteContainerSuccessfully(t)
+
+	res := Delete(fake.ServiceClient(), "testContainer")
+	th.CheckNoErr(t, res.Err)
+}
+
+func TestUpateContainer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleUpdateContainerSuccessfully(t)
+
+	options := &UpdateOpts{Metadata: map[string]string{"foo": "bar"}}
+	res := Update(fake.ServiceClient(), "testContainer", options)
+	th.CheckNoErr(t, res.Err)
+}
+
+func TestGetContainer(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetContainerSuccessfully(t)
+
+	_, err := Get(fake.ServiceClient(), "testContainer").ExtractMetadata()
+	th.CheckNoErr(t, err)
+}
diff --git a/openstack/objectstorage/v1/containers/results.go b/openstack/objectstorage/v1/containers/results.go
new file mode 100644
index 0000000..9eec3f4
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/results.go
@@ -0,0 +1,197 @@
+package containers
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Container represents a container resource.
+type Container struct {
+	// The total number of bytes stored in the container.
+	Bytes int `json:"bytes"`
+
+	// The total number of objects stored in the container.
+	Count int `json:"count"`
+
+	// The name of the container.
+	Name string `json:"name"`
+}
+
+// ContainerPage is the page returned by a pager when traversing over a
+// collection of containers.
+type ContainerPage struct {
+	pagination.MarkerPageBase
+}
+
+//IsEmpty returns true if a ListResult contains no container names.
+func (r ContainerPage) IsEmpty() (bool, error) {
+	names, err := ExtractNames(r)
+	return len(names) == 0, err
+}
+
+// LastMarker returns the last container name in a ListResult.
+func (r ContainerPage) LastMarker() (string, error) {
+	names, err := ExtractNames(r)
+	if err != nil {
+		return "", err
+	}
+	if len(names) == 0 {
+		return "", nil
+	}
+	return names[len(names)-1], nil
+}
+
+// ExtractInfo is a function that takes a ListResult and returns the containers' information.
+func ExtractInfo(r pagination.Page) ([]Container, error) {
+	var s []Container
+	err := (r.(ContainerPage)).ExtractInto(&s)
+	return s, err
+}
+
+// ExtractNames is a function that takes a ListResult and returns the containers' names.
+func ExtractNames(page pagination.Page) ([]string, error) {
+	casted := page.(ContainerPage)
+	ct := casted.Header.Get("Content-Type")
+
+	switch {
+	case strings.HasPrefix(ct, "application/json"):
+		parsed, err := ExtractInfo(page)
+		if err != nil {
+			return nil, err
+		}
+
+		names := make([]string, 0, len(parsed))
+		for _, container := range parsed {
+			names = append(names, container.Name)
+		}
+		return names, nil
+	case strings.HasPrefix(ct, "text/plain"):
+		names := make([]string, 0, 50)
+
+		body := string(page.(ContainerPage).Body.([]uint8))
+		for _, name := range strings.Split(body, "\n") {
+			if len(name) > 0 {
+				names = append(names, name)
+			}
+		}
+
+		return names, nil
+	default:
+		return nil, fmt.Errorf("Cannot extract names from response with content-type: [%s]", ct)
+	}
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+	AcceptRanges     string                  `json:"Accept-Ranges"`
+	BytesUsed        string                  `json:"X-Account-Bytes-Used"`
+	ContentLength    string                  `json:"Content-Length"`
+	ContentType      string                  `json:"Content-Type"`
+	Date             gophercloud.JSONRFC1123 `json:"Date"`
+	ObjectCount      string                  `json:"X-Container-Object-Count"`
+	Read             string                  `json:"X-Container-Read"`
+	TransID          string                  `json:"X-Trans-Id"`
+	VersionsLocation string                  `json:"X-Versions-Location"`
+	Write            string                  `json:"X-Container-Write"`
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (r GetResult) Extract() (*GetHeader, error) {
+	var s *GetHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the container.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	metadata := make(map[string]string)
+	for k, v := range r.Header {
+		if strings.HasPrefix(k, "X-Container-Meta-") {
+			key := strings.TrimPrefix(k, "X-Container-Meta-")
+			metadata[key] = v[0]
+		}
+	}
+	return metadata, nil
+}
+
+// CreateHeader represents the headers returned in the response from a Create request.
+type CreateHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// CreateResult represents the result of a create operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type CreateResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Create. To obtain
+// a map of headers, call the ExtractHeader method on the CreateResult.
+func (r CreateResult) Extract() (*CreateHeader, error) {
+	var s *CreateHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// UpdateResult represents the result of an update operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type UpdateResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Update. To obtain
+// a map of headers, call the ExtractHeader method on the UpdateResult.
+func (r UpdateResult) Extract() (*UpdateHeader, error) {
+	var s *UpdateHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// DeleteResult represents the result of a delete operation. To extract the
+// the headers from the HTTP response, you can invoke the 'ExtractHeader'
+// method on the result struct.
+type DeleteResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Delete. To obtain
+// a map of headers, call the ExtractHeader method on the DeleteResult.
+func (r DeleteResult) Extract() (*DeleteHeader, error) {
+	var s *DeleteHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/objectstorage/v1/containers/urls.go b/openstack/objectstorage/v1/containers/urls.go
new file mode 100644
index 0000000..9b38047
--- /dev/null
+++ b/openstack/objectstorage/v1/containers/urls.go
@@ -0,0 +1,23 @@
+package containers
+
+import "github.com/gophercloud/gophercloud"
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return c.Endpoint
+}
+
+func createURL(c *gophercloud.ServiceClient, container string) string {
+	return c.ServiceURL(container)
+}
+
+func getURL(c *gophercloud.ServiceClient, container string) string {
+	return createURL(c, container)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, container string) string {
+	return createURL(c, container)
+}
+
+func updateURL(c *gophercloud.ServiceClient, container string) string {
+	return createURL(c, container)
+}
diff --git a/openstack/objectstorage/v1/objects/doc.go b/openstack/objectstorage/v1/objects/doc.go
new file mode 100644
index 0000000..30a9add
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/doc.go
@@ -0,0 +1,5 @@
+// Package objects contains functionality for working with Object Storage
+// object resources. An object is a resource that represents and contains data
+// - such as documents, images, and so on. You can also store custom metadata
+// with an object.
+package objects
diff --git a/openstack/objectstorage/v1/objects/errors.go b/openstack/objectstorage/v1/objects/errors.go
new file mode 100644
index 0000000..5c4ae44
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/errors.go
@@ -0,0 +1,13 @@
+package objects
+
+import "github.com/gophercloud/gophercloud"
+
+// ErrWrongChecksum is the error when the checksum generated for an object
+// doesn't match the ETAG header.
+type ErrWrongChecksum struct {
+	gophercloud.BaseError
+}
+
+func (e ErrWrongChecksum) Error() string {
+	return "Local checksum does not match API ETag header"
+}
diff --git a/openstack/objectstorage/v1/objects/fixtures.go b/openstack/objectstorage/v1/objects/fixtures.go
new file mode 100644
index 0000000..999b305
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/fixtures.go
@@ -0,0 +1,195 @@
+// +build fixtures
+
+package objects
+
+import (
+	"crypto/md5"
+	"fmt"
+	"io"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// HandleDownloadObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Download` response.
+func HandleDownloadObjectSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, "Successful download with Gophercloud")
+	})
+}
+
+// ExpectedListInfo is the result expected from a call to `List` when full
+// info is requested.
+var ExpectedListInfo = []Object{
+	Object{
+		Hash:         "451e372e48e0f6b1114fa0724aa79fa1",
+		LastModified: "2009-11-10 23:00:00 +0000 UTC",
+		Bytes:        14,
+		Name:         "goodbye",
+		ContentType:  "application/octet-stream",
+	},
+	Object{
+		Hash:         "451e372e48e0f6b1114fa0724aa79fa1",
+		LastModified: "2009-11-10 23:00:00 +0000 UTC",
+		Bytes:        14,
+		Name:         "hello",
+		ContentType:  "application/octet-stream",
+	},
+}
+
+// ExpectedListNames is the result expected from a call to `List` when just
+// object names are requested.
+var ExpectedListNames = []string{"hello", "goodbye"}
+
+// HandleListObjectsInfoSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `List` response when full info is requested.
+func HandleListObjectsInfoSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, `[
+      {
+        "hash": "451e372e48e0f6b1114fa0724aa79fa1",
+        "last_modified": "2009-11-10 23:00:00 +0000 UTC",
+        "bytes": 14,
+        "name": "goodbye",
+        "content_type": "application/octet-stream"
+      },
+      {
+        "hash": "451e372e48e0f6b1114fa0724aa79fa1",
+        "last_modified": "2009-11-10 23:00:00 +0000 UTC",
+        "bytes": 14,
+        "name": "hello",
+        "content_type": "application/octet-stream"
+      }
+    ]`)
+		case "hello":
+			fmt.Fprintf(w, `[]`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// HandleListObjectNamesSuccessfully creates an HTTP handler at `/testContainer` on the test handler mux that
+// responds with a `List` response when only object names are requested.
+func HandleListObjectNamesSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "text/plain")
+
+		w.Header().Set("Content-Type", "text/plain")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, "hello\ngoodbye\n")
+		case "goodbye":
+			fmt.Fprintf(w, "")
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// HandleCreateTextObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux
+// that responds with a `Create` response. A Content-Type of "text/plain" is expected.
+func HandleCreateTextObjectSuccessfully(t *testing.T, content string) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "text/plain")
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		hash := md5.New()
+		io.WriteString(hash, content)
+		localChecksum := hash.Sum(nil)
+
+		w.Header().Set("ETag", fmt.Sprintf("%x", localChecksum))
+		w.WriteHeader(http.StatusCreated)
+	})
+}
+
+// HandleCreateTypelessObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler
+// mux that responds with a `Create` response. No Content-Type header may be present in the request, so that server-
+// side content-type detection will be triggered properly.
+func HandleCreateTypelessObjectSuccessfully(t *testing.T, content string) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		if contentType, present := r.Header["Content-Type"]; present {
+			t.Errorf("Expected Content-Type header to be omitted, but was %#v", contentType)
+		}
+
+		hash := md5.New()
+		io.WriteString(hash, content)
+		localChecksum := hash.Sum(nil)
+
+		w.Header().Set("ETag", fmt.Sprintf("%x", localChecksum))
+		w.WriteHeader(http.StatusCreated)
+	})
+}
+
+// HandleCopyObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Copy` response.
+func HandleCopyObjectSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "COPY")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "Destination", "/newTestContainer/newTestObject")
+		w.WriteHeader(http.StatusCreated)
+	})
+}
+
+// HandleDeleteObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Delete` response.
+func HandleDeleteObjectSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// HandleUpdateObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Update` response.
+func HandleUpdateObjectSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "X-Object-Meta-Gophercloud-Test", "objects")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleGetObjectSuccessfully creates an HTTP handler at `/testContainer/testObject` on the test handler mux that
+// responds with a `Get` response.
+func HandleGetObjectSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "HEAD")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.Header().Add("X-Object-Meta-Gophercloud-Test", "objects")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/openstack/objectstorage/v1/objects/requests.go b/openstack/objectstorage/v1/objects/requests.go
new file mode 100644
index 0000000..99ad9a7
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/requests.go
@@ -0,0 +1,452 @@
+package objects
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/md5"
+	"crypto/sha1"
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack/objectstorage/v1/accounts"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the List
+// request.
+type ListOptsBuilder interface {
+	ToObjectListParams() (bool, string, error)
+}
+
+// ListOpts is a structure that holds parameters for listing objects.
+type ListOpts struct {
+	// Full is a true/false value that represents the amount of object information
+	// returned. If Full is set to true, then the content-type, number of bytes, hash
+	// date last modified, and name are returned. If set to false or not set, then
+	// only the object names are returned.
+	Full      bool
+	Limit     int    `q:"limit"`
+	Marker    string `q:"marker"`
+	EndMarker string `q:"end_marker"`
+	Format    string `q:"format"`
+	Prefix    string `q:"prefix"`
+	Delimiter string `q:"delimiter"`
+	Path      string `q:"path"`
+}
+
+// ToObjectListParams formats a ListOpts into a query string and boolean
+// representing whether to list complete information for each object.
+func (opts ListOpts) ToObjectListParams() (bool, string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return opts.Full, q.String(), err
+}
+
+// List is a function that retrieves all objects in a container. It also returns the details
+// for the container. To extract only the object information or names, pass the ListResult
+// response to the ExtractInfo or ExtractNames function, respectively.
+func List(c *gophercloud.ServiceClient, containerName string, opts ListOptsBuilder) pagination.Pager {
+	headers := map[string]string{"Accept": "text/plain", "Content-Type": "text/plain"}
+
+	url := listURL(c, containerName)
+	if opts != nil {
+		full, query, err := opts.ToObjectListParams()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+
+		if full {
+			headers = map[string]string{"Accept": "application/json", "Content-Type": "application/json"}
+		}
+	}
+
+	pager := pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+		p := ObjectPage{pagination.MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	})
+	pager.Headers = headers
+	return pager
+}
+
+// DownloadOptsBuilder allows extensions to add additional parameters to the
+// Download request.
+type DownloadOptsBuilder interface {
+	ToObjectDownloadParams() (map[string]string, string, error)
+}
+
+// DownloadOpts is a structure that holds parameters for downloading an object.
+type DownloadOpts struct {
+	IfMatch           string    `h:"If-Match"`
+	IfModifiedSince   time.Time `h:"If-Modified-Since"`
+	IfNoneMatch       string    `h:"If-None-Match"`
+	IfUnmodifiedSince time.Time `h:"If-Unmodified-Since"`
+	Range             string    `h:"Range"`
+	Expires           string    `q:"expires"`
+	MultipartManifest string    `q:"multipart-manifest"`
+	Signature         string    `q:"signature"`
+}
+
+// ToObjectDownloadParams formats a DownloadOpts into a query string and map of
+// headers.
+func (opts DownloadOpts) ToObjectDownloadParams() (map[string]string, string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	if err != nil {
+		return nil, "", err
+	}
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, q.String(), err
+	}
+	return h, q.String(), nil
+}
+
+// Download is a function that retrieves the content and metadata for an object.
+// To extract just the content, pass the DownloadResult response to the
+// ExtractContent function.
+func Download(c *gophercloud.ServiceClient, containerName, objectName string, opts DownloadOptsBuilder) (r DownloadResult) {
+	url := downloadURL(c, containerName, objectName)
+	h := make(map[string]string)
+	if opts != nil {
+		headers, query, err := opts.ToObjectDownloadParams()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		for k, v := range headers {
+			h[k] = v
+		}
+		url += query
+	}
+
+	resp, err := c.Get(url, nil, &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{200, 304},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+		r.Body = resp.Body
+	}
+	r.Err = err
+	return
+}
+
+// CreateOptsBuilder allows extensions to add additional parameters to the
+// Create request.
+type CreateOptsBuilder interface {
+	ToObjectCreateParams() (io.Reader, map[string]string, string, error)
+}
+
+// CreateOpts is a structure that holds parameters for creating an object.
+type CreateOpts struct {
+	Content            io.Reader
+	Metadata           map[string]string
+	ContentDisposition string `h:"Content-Disposition"`
+	ContentEncoding    string `h:"Content-Encoding"`
+	ContentLength      int64  `h:"Content-Length"`
+	ContentType        string `h:"Content-Type"`
+	CopyFrom           string `h:"X-Copy-From"`
+	DeleteAfter        int    `h:"X-Delete-After"`
+	DeleteAt           int    `h:"X-Delete-At"`
+	DetectContentType  string `h:"X-Detect-Content-Type"`
+	ETag               string `h:"ETag"`
+	IfNoneMatch        string `h:"If-None-Match"`
+	ObjectManifest     string `h:"X-Object-Manifest"`
+	TransferEncoding   string `h:"Transfer-Encoding"`
+	Expires            string `q:"expires"`
+	MultipartManifest  string `q:"multipart-manifest"`
+	Signature          string `q:"signature"`
+}
+
+// ToObjectCreateParams formats a CreateOpts into a query string and map of
+// headers.
+func (opts CreateOpts) ToObjectCreateParams() (io.Reader, map[string]string, string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	if err != nil {
+		return nil, nil, "", err
+	}
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, nil, "", err
+	}
+
+	for k, v := range opts.Metadata {
+		h["X-Object-Meta-"+k] = v
+	}
+
+	hash := md5.New()
+	buf := bytes.NewBuffer([]byte{})
+	_, err = io.Copy(io.MultiWriter(hash, buf), opts.Content)
+	if err != nil {
+		return nil, nil, "", err
+	}
+	localChecksum := fmt.Sprintf("%x", hash.Sum(nil))
+	h["ETag"] = localChecksum
+
+	return buf, h, q.String(), nil
+}
+
+// Create is a function that creates a new object or replaces an existing object. If the returned response's ETag
+// header fails to match the local checksum, the failed request will automatically be retried up to a maximum of 3 times.
+func Create(c *gophercloud.ServiceClient, containerName, objectName string, opts CreateOptsBuilder) (r CreateResult) {
+	url := createURL(c, containerName, objectName)
+	h := make(map[string]string)
+	var b io.Reader
+	if opts != nil {
+		tmpB, headers, query, err := opts.ToObjectCreateParams()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		for k, v := range headers {
+			h[k] = v
+		}
+		url += query
+		b = tmpB
+	}
+
+	resp, err := c.Put(url, nil, nil, &gophercloud.RequestOpts{
+		RawBody:     b,
+		MoreHeaders: h,
+	})
+	r.Err = err
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	return
+}
+
+// CopyOptsBuilder allows extensions to add additional parameters to the
+// Copy request.
+type CopyOptsBuilder interface {
+	ToObjectCopyMap() (map[string]string, error)
+}
+
+// CopyOpts is a structure that holds parameters for copying one object to
+// another.
+type CopyOpts struct {
+	Metadata           map[string]string
+	ContentDisposition string `h:"Content-Disposition"`
+	ContentEncoding    string `h:"Content-Encoding"`
+	ContentType        string `h:"Content-Type"`
+	Destination        string `h:"Destination" required:"true"`
+}
+
+// ToObjectCopyMap formats a CopyOpts into a map of headers.
+func (opts CopyOpts) ToObjectCopyMap() (map[string]string, error) {
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range opts.Metadata {
+		h["X-Object-Meta-"+k] = v
+	}
+	return h, nil
+}
+
+// Copy is a function that copies one object to another.
+func Copy(c *gophercloud.ServiceClient, containerName, objectName string, opts CopyOptsBuilder) (r CopyResult) {
+	h := make(map[string]string)
+	headers, err := opts.ToObjectCopyMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+
+	for k, v := range headers {
+		h[k] = v
+	}
+
+	url := copyURL(c, containerName, objectName)
+	resp, err := c.Request("COPY", url, &gophercloud.RequestOpts{
+		MoreHeaders: h,
+		OkCodes:     []int{201},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// DeleteOptsBuilder allows extensions to add additional parameters to the
+// Delete request.
+type DeleteOptsBuilder interface {
+	ToObjectDeleteQuery() (string, error)
+}
+
+// DeleteOpts is a structure that holds parameters for deleting an object.
+type DeleteOpts struct {
+	MultipartManifest string `q:"multipart-manifest"`
+}
+
+// ToObjectDeleteQuery formats a DeleteOpts into a query string.
+func (opts DeleteOpts) ToObjectDeleteQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// Delete is a function that deletes an object.
+func Delete(c *gophercloud.ServiceClient, containerName, objectName string, opts DeleteOptsBuilder) (r DeleteResult) {
+	url := deleteURL(c, containerName, objectName)
+	if opts != nil {
+		query, err := opts.ToObjectDeleteQuery()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		url += query
+	}
+	resp, err := c.Delete(url, nil)
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// GetOptsBuilder allows extensions to add additional parameters to the
+// Get request.
+type GetOptsBuilder interface {
+	ToObjectGetQuery() (string, error)
+}
+
+// GetOpts is a structure that holds parameters for getting an object's metadata.
+type GetOpts struct {
+	Expires   string `q:"expires"`
+	Signature string `q:"signature"`
+}
+
+// ToObjectGetQuery formats a GetOpts into a query string.
+func (opts GetOpts) ToObjectGetQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// Get is a function that retrieves the metadata of an object. To extract just the custom
+// metadata, pass the GetResult response to the ExtractMetadata function.
+func Get(c *gophercloud.ServiceClient, containerName, objectName string, opts GetOptsBuilder) (r GetResult) {
+	url := getURL(c, containerName, objectName)
+	if opts != nil {
+		query, err := opts.ToObjectGetQuery()
+		if err != nil {
+			r.Err = err
+			return
+		}
+		url += query
+	}
+	resp, err := c.Request("HEAD", url, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 204},
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// UpdateOptsBuilder allows extensions to add additional parameters to the
+// Update request.
+type UpdateOptsBuilder interface {
+	ToObjectUpdateMap() (map[string]string, error)
+}
+
+// UpdateOpts is a structure that holds parameters for updating, creating, or deleting an
+// object's metadata.
+type UpdateOpts struct {
+	Metadata           map[string]string
+	ContentDisposition string `h:"Content-Disposition"`
+	ContentEncoding    string `h:"Content-Encoding"`
+	ContentType        string `h:"Content-Type"`
+	DeleteAfter        int    `h:"X-Delete-After"`
+	DeleteAt           int    `h:"X-Delete-At"`
+	DetectContentType  bool   `h:"X-Detect-Content-Type"`
+}
+
+// ToObjectUpdateMap formats a UpdateOpts into a map of headers.
+func (opts UpdateOpts) ToObjectUpdateMap() (map[string]string, error) {
+	h, err := gophercloud.BuildHeaders(opts)
+	if err != nil {
+		return nil, err
+	}
+	for k, v := range opts.Metadata {
+		h["X-Object-Meta-"+k] = v
+	}
+	return h, nil
+}
+
+// Update is a function that creates, updates, or deletes an object's metadata.
+func Update(c *gophercloud.ServiceClient, containerName, objectName string, opts UpdateOptsBuilder) (r UpdateResult) {
+	h := c.AuthenticatedHeaders()
+	if opts != nil {
+		headers, err := opts.ToObjectUpdateMap()
+		if err != nil {
+			r.Err = err
+			return
+		}
+
+		for k, v := range headers {
+			h[k] = v
+		}
+	}
+	url := updateURL(c, containerName, objectName)
+	resp, err := c.Post(url, nil, nil, &gophercloud.RequestOpts{
+		MoreHeaders: h,
+	})
+	if resp != nil {
+		r.Header = resp.Header
+	}
+	r.Err = err
+	return
+}
+
+// HTTPMethod represents an HTTP method string (e.g. "GET").
+type HTTPMethod string
+
+var (
+	// GET represents an HTTP "GET" method.
+	GET HTTPMethod = "GET"
+	// POST represents an HTTP "POST" method.
+	POST HTTPMethod = "POST"
+)
+
+// CreateTempURLOpts are options for creating a temporary URL for an object.
+type CreateTempURLOpts struct {
+	// (REQUIRED) Method is the HTTP method to allow for users of the temp URL. Valid values
+	// are "GET" and "POST".
+	Method HTTPMethod
+	// (REQUIRED) TTL is the number of seconds the temp URL should be active.
+	TTL int
+	// (Optional) Split is the string on which to split the object URL. Since only
+	// the object path is used in the hash, the object URL needs to be parsed. If
+	// empty, the default OpenStack URL split point will be used ("/v1/").
+	Split string
+}
+
+// CreateTempURL is a function for creating a temporary URL for an object. It
+// allows users to have "GET" or "POST" access to a particular tenant's object
+// for a limited amount of time.
+func CreateTempURL(c *gophercloud.ServiceClient, containerName, objectName string, opts CreateTempURLOpts) (string, error) {
+	if opts.Split == "" {
+		opts.Split = "/v1/"
+	}
+	duration := time.Duration(opts.TTL) * time.Second
+	expiry := time.Now().Add(duration).Unix()
+	getHeader, err := accounts.Get(c, nil).Extract()
+	if err != nil {
+		return "", err
+	}
+	secretKey := []byte(getHeader.TempURLKey)
+	url := getURL(c, containerName, objectName)
+	splitPath := strings.Split(url, opts.Split)
+	baseURL, objectPath := splitPath[0], splitPath[1]
+	objectPath = opts.Split + objectPath
+	body := fmt.Sprintf("%s\n%d\n%s", opts.Method, expiry, objectPath)
+	hash := hmac.New(sha1.New, secretKey)
+	hash.Write([]byte(body))
+	hexsum := fmt.Sprintf("%x", hash.Sum(nil))
+	return fmt.Sprintf("%s%s?temp_url_sig=%s&temp_url_expires=%d", baseURL, objectPath, hexsum, expiry), nil
+}
diff --git a/openstack/objectstorage/v1/objects/requests_test.go b/openstack/objectstorage/v1/objects/requests_test.go
new file mode 100644
index 0000000..e612319
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/requests_test.go
@@ -0,0 +1,165 @@
+package objects
+
+import (
+	"bytes"
+	"io"
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestDownloadReader(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDownloadObjectSuccessfully(t)
+
+	response := Download(fake.ServiceClient(), "testContainer", "testObject", nil)
+	defer response.Body.Close()
+
+	// Check reader
+	buf := bytes.NewBuffer(make([]byte, 0))
+	io.CopyN(buf, response.Body, 10)
+	th.CheckEquals(t, "Successful", string(buf.Bytes()))
+}
+
+func TestDownloadExtraction(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDownloadObjectSuccessfully(t)
+
+	response := Download(fake.ServiceClient(), "testContainer", "testObject", nil)
+
+	// Check []byte extraction
+	bytes, err := response.ExtractContent()
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, "Successful download with Gophercloud", string(bytes))
+}
+
+func TestListObjectInfo(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListObjectsInfoSuccessfully(t)
+
+	count := 0
+	options := &ListOpts{Full: true}
+	err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractInfo(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ExpectedListInfo, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestListObjectNames(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListObjectNamesSuccessfully(t)
+
+	count := 0
+	options := &ListOpts{Full: false}
+	err := List(fake.ServiceClient(), "testContainer", options).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractNames(page)
+		if err != nil {
+			t.Errorf("Failed to extract container names: %v", err)
+			return false, err
+		}
+
+		th.CheckDeepEquals(t, ExpectedListNames, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestCreateObject(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	content := "Did gyre and gimble in the wabe"
+
+	HandleCreateTextObjectSuccessfully(t, content)
+
+	options := &CreateOpts{ContentType: "text/plain", Content: strings.NewReader(content)}
+	res := Create(fake.ServiceClient(), "testContainer", "testObject", options)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestCreateObjectWithoutContentType(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	content := "The sky was the color of television, tuned to a dead channel."
+
+	HandleCreateTypelessObjectSuccessfully(t, content)
+
+	res := Create(fake.ServiceClient(), "testContainer", "testObject", &CreateOpts{Content: strings.NewReader(content)})
+	th.AssertNoErr(t, res.Err)
+}
+
+/*
+func TestErrorIsRaisedForChecksumMismatch(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/testContainer/testObject", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("ETag", "acbd18db4cc2f85cedef654fccc4a4d8")
+		w.WriteHeader(http.StatusCreated)
+	})
+
+	content := strings.NewReader("The sky was the color of television, tuned to a dead channel.")
+	res := Create(fake.ServiceClient(), "testContainer", "testObject", &CreateOpts{Content: content})
+
+	err := fmt.Errorf("Local checksum does not match API ETag header")
+	th.AssertDeepEquals(t, err, res.Err)
+}
+*/
+
+func TestCopyObject(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCopyObjectSuccessfully(t)
+
+	options := &CopyOpts{Destination: "/newTestContainer/newTestObject"}
+	res := Copy(fake.ServiceClient(), "testContainer", "testObject", options)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestDeleteObject(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteObjectSuccessfully(t)
+
+	res := Delete(fake.ServiceClient(), "testContainer", "testObject", nil)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestUpateObjectMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleUpdateObjectSuccessfully(t)
+
+	options := &UpdateOpts{Metadata: map[string]string{"Gophercloud-Test": "objects"}}
+	res := Update(fake.ServiceClient(), "testContainer", "testObject", options)
+	th.AssertNoErr(t, res.Err)
+}
+
+func TestGetObject(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetObjectSuccessfully(t)
+
+	expected := map[string]string{"Gophercloud-Test": "objects"}
+	actual, err := Get(fake.ServiceClient(), "testContainer", "testObject", nil).ExtractMetadata()
+	th.AssertNoErr(t, err)
+	th.CheckDeepEquals(t, expected, actual)
+}
diff --git a/openstack/objectstorage/v1/objects/results.go b/openstack/objectstorage/v1/objects/results.go
new file mode 100644
index 0000000..3cfe1f4
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/results.go
@@ -0,0 +1,283 @@
+package objects
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Object is a structure that holds information related to a storage object.
+type Object struct {
+	// Bytes is the total number of bytes that comprise the object.
+	Bytes int64 `json:"bytes"`
+
+	// ContentType is the content type of the object.
+	ContentType string `json:"content_type"`
+
+	// Hash represents the MD5 checksum value of the object's content.
+	Hash string `json:"hash"`
+
+	// LastModified is the RFC3339Milli time the object was last modified, represented
+	// as a string. For any given object (obj), this value may be parsed to a time.Time:
+	// lastModified, err := time.Parse(gophercloud.RFC3339Milli, obj.LastModified)
+	LastModified string `json:"last_modified"`
+
+	// Name is the unique name for the object.
+	Name string `json:"name"`
+}
+
+// ObjectPage is a single page of objects that is returned from a call to the
+// List function.
+type ObjectPage struct {
+	pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a ListResult contains no object names.
+func (r ObjectPage) IsEmpty() (bool, error) {
+	names, err := ExtractNames(r)
+	return len(names) == 0, err
+}
+
+// LastMarker returns the last object name in a ListResult.
+func (r ObjectPage) LastMarker() (string, error) {
+	names, err := ExtractNames(r)
+	if err != nil {
+		return "", err
+	}
+	if len(names) == 0 {
+		return "", nil
+	}
+	return names[len(names)-1], nil
+}
+
+// ExtractInfo is a function that takes a page of objects and returns their full information.
+func ExtractInfo(r pagination.Page) ([]Object, error) {
+	var s []Object
+	err := (r.(ObjectPage)).ExtractInto(&s)
+	return s, err
+}
+
+// ExtractNames is a function that takes a page of objects and returns only their names.
+func ExtractNames(r pagination.Page) ([]string, error) {
+	casted := r.(ObjectPage)
+	ct := casted.Header.Get("Content-Type")
+	switch {
+	case strings.HasPrefix(ct, "application/json"):
+		parsed, err := ExtractInfo(r)
+		if err != nil {
+			return nil, err
+		}
+
+		names := make([]string, 0, len(parsed))
+		for _, object := range parsed {
+			names = append(names, object.Name)
+		}
+
+		return names, nil
+	case strings.HasPrefix(ct, "text/plain"):
+		names := make([]string, 0, 50)
+
+		body := string(r.(ObjectPage).Body.([]uint8))
+		for _, name := range strings.Split(body, "\n") {
+			if len(name) > 0 {
+				names = append(names, name)
+			}
+		}
+
+		return names, nil
+	case strings.HasPrefix(ct, "text/html"):
+		return []string{}, nil
+	default:
+		return nil, fmt.Errorf("Cannot extract names from response with content-type: [%s]", ct)
+	}
+}
+
+// DownloadHeader represents the headers returned in the response from a Download request.
+type DownloadHeader struct {
+	AcceptRanges       string                  `json:"Accept-Ranges"`
+	ContentDisposition string                  `json:"Content-Disposition"`
+	ContentEncoding    string                  `json:"Content-Encoding"`
+	ContentLength      string                  `json:"Content-Length"`
+	ContentType        string                  `json:"Content-Type"`
+	Date               gophercloud.JSONRFC1123 `json:"Date"`
+	DeleteAt           gophercloud.JSONUnix    `json:"X-Delete-At"`
+	ETag               string                  `json:"Etag"`
+	LastModified       gophercloud.JSONRFC1123 `json:"Last-Modified"`
+	ObjectManifest     string                  `json:"X-Object-Manifest"`
+	StaticLargeObject  bool                    `json:"X-Static-Large-Object"`
+	TransID            string                  `json:"X-Trans-Id"`
+}
+
+// DownloadResult is a *http.Response that is returned from a call to the Download function.
+type DownloadResult struct {
+	gophercloud.HeaderResult
+	Body io.ReadCloser
+}
+
+// Extract will return a struct of headers returned from a call to Download. To obtain
+// a map of headers, call the ExtractHeader method on the DownloadResult.
+func (r DownloadResult) Extract() (*DownloadHeader, error) {
+	var s *DownloadHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// ExtractContent is a function that takes a DownloadResult's io.Reader body
+// and reads all available data into a slice of bytes. Please be aware that due
+// the nature of io.Reader is forward-only - meaning that it can only be read
+// once and not rewound. You can recreate a reader from the output of this
+// function by using bytes.NewReader(downloadBytes)
+func (r *DownloadResult) ExtractContent() ([]byte, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	defer r.Body.Close()
+	body, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		return nil, err
+	}
+	r.Body.Close()
+	return body, nil
+}
+
+// GetHeader represents the headers returned in the response from a Get request.
+type GetHeader struct {
+	ContentDisposition string                  `json:"Content-Disposition"`
+	ContentEncoding    string                  `json:"Content-Encoding"`
+	ContentLength      string                  `json:"Content-Length"`
+	ContentType        string                  `json:"Content-Type"`
+	Date               gophercloud.JSONRFC1123 `json:"Date"`
+	DeleteAt           gophercloud.JSONUnix    `json:"X-Delete-At"`
+	ETag               string                  `json:"Etag"`
+	LastModified       gophercloud.JSONRFC1123 `json:"Last-Modified"`
+	ObjectManifest     string                  `json:"X-Object-Manifest"`
+	StaticLargeObject  bool                    `json:"X-Static-Large-Object"`
+	TransID            string                  `json:"X-Trans-Id"`
+}
+
+// GetResult is a *http.Response that is returned from a call to the Get function.
+type GetResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Get. To obtain
+// a map of headers, call the ExtractHeader method on the GetResult.
+func (r GetResult) Extract() (*GetHeader, error) {
+	var s *GetHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// ExtractMetadata is a function that takes a GetResult (of type *http.Response)
+// and returns the custom metadata associated with the object.
+func (r GetResult) ExtractMetadata() (map[string]string, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	metadata := make(map[string]string)
+	for k, v := range r.Header {
+		if strings.HasPrefix(k, "X-Object-Meta-") {
+			key := strings.TrimPrefix(k, "X-Object-Meta-")
+			metadata[key] = v[0]
+		}
+	}
+	return metadata, nil
+}
+
+// CreateHeader represents the headers returned in the response from a Create request.
+type CreateHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	ETag          string                  `json:"Etag"`
+	LastModified  gophercloud.JSONRFC1123 `json:"Last-Modified"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	checksum string
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Create. To obtain
+// a map of headers, call the ExtractHeader method on the CreateResult.
+func (r CreateResult) Extract() (*CreateHeader, error) {
+	//if r.Header.Get("ETag") != fmt.Sprintf("%x", localChecksum) {
+	//	return nil, ErrWrongChecksum{}
+	//}
+	var s *CreateHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// UpdateHeader represents the headers returned in the response from a Update request.
+type UpdateHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Update. To obtain
+// a map of headers, call the ExtractHeader method on the UpdateResult.
+func (r UpdateResult) Extract() (*UpdateHeader, error) {
+	var s *UpdateHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// DeleteHeader represents the headers returned in the response from a Delete request.
+type DeleteHeader struct {
+	ContentLength string                  `json:"Content-Length"`
+	ContentType   string                  `json:"Content-Type"`
+	Date          gophercloud.JSONRFC1123 `json:"Date"`
+	TransID       string                  `json:"X-Trans-Id"`
+}
+
+// DeleteResult represents the result of a delete operation.
+type DeleteResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Delete. To obtain
+// a map of headers, call the ExtractHeader method on the DeleteResult.
+func (r DeleteResult) Extract() (*DeleteHeader, error) {
+	var s *DeleteHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// CopyHeader represents the headers returned in the response from a Copy request.
+type CopyHeader struct {
+	ContentLength          int64                   `json:"Content-Length"`
+	ContentType            string                  `json:"Content-Type"`
+	CopiedFrom             string                  `json:"X-Copied-From"`
+	CopiedFromLastModified gophercloud.JSONRFC1123 `json:"X-Copied-From-Last-Modified"`
+	Date                   gophercloud.JSONRFC1123 `json:"Date"`
+	ETag                   string                  `json:"Etag"`
+	LastModified           gophercloud.JSONRFC1123 `json:"Last-Modified"`
+	TransID                string                  `json:"X-Trans-Id"`
+}
+
+// CopyResult represents the result of a copy operation.
+type CopyResult struct {
+	gophercloud.HeaderResult
+}
+
+// Extract will return a struct of headers returned from a call to Copy. To obtain
+// a map of headers, call the ExtractHeader method on the CopyResult.
+func (r CopyResult) Extract() (*CopyHeader, error) {
+	var s *CopyHeader
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/objectstorage/v1/objects/urls.go b/openstack/objectstorage/v1/objects/urls.go
new file mode 100644
index 0000000..b3ac304
--- /dev/null
+++ b/openstack/objectstorage/v1/objects/urls.go
@@ -0,0 +1,33 @@
+package objects
+
+import (
+	"github.com/gophercloud/gophercloud"
+)
+
+func listURL(c *gophercloud.ServiceClient, container string) string {
+	return c.ServiceURL(container)
+}
+
+func copyURL(c *gophercloud.ServiceClient, container, object string) string {
+	return c.ServiceURL(container, object)
+}
+
+func createURL(c *gophercloud.ServiceClient, container, object string) string {
+	return copyURL(c, container, object)
+}
+
+func getURL(c *gophercloud.ServiceClient, container, object string) string {
+	return copyURL(c, container, object)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, container, object string) string {
+	return copyURL(c, container, object)
+}
+
+func downloadURL(c *gophercloud.ServiceClient, container, object string) string {
+	return copyURL(c, container, object)
+}
+
+func updateURL(c *gophercloud.ServiceClient, container, object string) string {
+	return copyURL(c, container, object)
+}
diff --git a/openstack/orchestration/v1/apiversions/doc.go b/openstack/orchestration/v1/apiversions/doc.go
new file mode 100644
index 0000000..f2db622
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/doc.go
@@ -0,0 +1,4 @@
+// Package apiversions provides information and interaction with the different
+// API versions for the OpenStack Heat service. This functionality is not
+// restricted to this particular version.
+package apiversions
diff --git a/openstack/orchestration/v1/apiversions/requests.go b/openstack/orchestration/v1/apiversions/requests.go
new file mode 100644
index 0000000..ff383cf
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/requests.go
@@ -0,0 +1,13 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListVersions lists all the Neutron API versions available to end-users
+func ListVersions(c *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(c, apiVersionsURL(c), func(r pagination.PageResult) pagination.Page {
+		return APIVersionPage{pagination.SinglePageBase(r)}
+	})
+}
diff --git a/openstack/orchestration/v1/apiversions/requests_test.go b/openstack/orchestration/v1/apiversions/requests_test.go
new file mode 100644
index 0000000..477b804
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/requests_test.go
@@ -0,0 +1,89 @@
+package apiversions
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "versions": [
+        {
+            "status": "CURRENT",
+            "id": "v1.0",
+            "links": [
+                {
+                    "href": "http://23.253.228.211:8000/v1",
+                    "rel": "self"
+                }
+            ]
+        }
+    ]
+}`)
+	})
+
+	count := 0
+
+	ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractAPIVersions(page)
+		if err != nil {
+			t.Errorf("Failed to extract API versions: %v", err)
+			return false, err
+		}
+
+		expected := []APIVersion{
+			APIVersion{
+				Status: "CURRENT",
+				ID:     "v1.0",
+				Links: []gophercloud.Link{
+					gophercloud.Link{
+						Href: "http://23.253.228.211:8000/v1",
+						Rel:  "self",
+					},
+				},
+			},
+		}
+
+		th.AssertDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestNonJSONCannotBeExtractedIntoAPIVersions(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	th.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	})
+
+	ListVersions(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		if _, err := ExtractAPIVersions(page); err == nil {
+			t.Fatalf("Expected error, got nil")
+		}
+		return true, nil
+	})
+}
diff --git a/openstack/orchestration/v1/apiversions/results.go b/openstack/orchestration/v1/apiversions/results.go
new file mode 100644
index 0000000..a7c22a2
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/results.go
@@ -0,0 +1,36 @@
+package apiversions
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// APIVersion represents an API version for Neutron. It contains the status of
+// the API, and its unique ID.
+type APIVersion struct {
+	Status string             `json:"status"`
+	ID     string             `json:"id"`
+	Links  []gophercloud.Link `json:"links"`
+}
+
+// APIVersionPage is the page returned by a pager when traversing over a
+// collection of API versions.
+type APIVersionPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty checks whether an APIVersionPage struct is empty.
+func (r APIVersionPage) IsEmpty() (bool, error) {
+	is, err := ExtractAPIVersions(r)
+	return len(is) == 0, err
+}
+
+// ExtractAPIVersions takes a collection page, extracts all of the elements,
+// and returns them a slice of APIVersion structs. It is effectively a cast.
+func ExtractAPIVersions(r pagination.Page) ([]APIVersion, error) {
+	var s struct {
+		APIVersions []APIVersion `json:"versions"`
+	}
+	err := (r.(APIVersionPage)).ExtractInto(&s)
+	return s.APIVersions, err
+}
diff --git a/openstack/orchestration/v1/apiversions/urls.go b/openstack/orchestration/v1/apiversions/urls.go
new file mode 100644
index 0000000..0205405
--- /dev/null
+++ b/openstack/orchestration/v1/apiversions/urls.go
@@ -0,0 +1,7 @@
+package apiversions
+
+import "github.com/gophercloud/gophercloud"
+
+func apiVersionsURL(c *gophercloud.ServiceClient) string {
+	return c.Endpoint
+}
diff --git a/openstack/orchestration/v1/buildinfo/doc.go b/openstack/orchestration/v1/buildinfo/doc.go
new file mode 100644
index 0000000..183e8df
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/doc.go
@@ -0,0 +1,2 @@
+// Package buildinfo provides build information about heat deployments.
+package buildinfo
diff --git a/openstack/orchestration/v1/buildinfo/fixtures.go b/openstack/orchestration/v1/buildinfo/fixtures.go
new file mode 100644
index 0000000..4e93126
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/fixtures.go
@@ -0,0 +1,45 @@
+package buildinfo
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &BuildInfo{
+	API: Revision{
+		Revision: "2.4.5",
+	},
+	Engine: Revision{
+		Revision: "1.2.1",
+	},
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+  "api": {
+    "revision": "2.4.5"
+  },
+  "engine": {
+    "revision": "1.2.1"
+  }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/build_info`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/build_info", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
diff --git a/openstack/orchestration/v1/buildinfo/requests.go b/openstack/orchestration/v1/buildinfo/requests.go
new file mode 100644
index 0000000..32f6032
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/requests.go
@@ -0,0 +1,9 @@
+package buildinfo
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient) (r GetResult) {
+	_, r.Err = c.Get(getURL(c), &r.Body, nil)
+	return
+}
diff --git a/openstack/orchestration/v1/buildinfo/requests_test.go b/openstack/orchestration/v1/buildinfo/requests_test.go
new file mode 100644
index 0000000..18d15b3
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/requests_test.go
@@ -0,0 +1,20 @@
+package buildinfo
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t, GetOutput)
+
+	actual, err := Get(fake.ServiceClient()).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/buildinfo/results.go b/openstack/orchestration/v1/buildinfo/results.go
new file mode 100644
index 0000000..c3d2cdb
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/results.go
@@ -0,0 +1,29 @@
+package buildinfo
+
+import (
+	"github.com/gophercloud/gophercloud"
+)
+
+// Revision represents the API/Engine revision of a Heat deployment.
+type Revision struct {
+	Revision string `json:"revision"`
+}
+
+// BuildInfo represents the build information for a Heat deployment.
+type BuildInfo struct {
+	API    Revision `json:"api"`
+	Engine Revision `json:"engine"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a BuildInfo object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*BuildInfo, error) {
+	var s *BuildInfo
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/orchestration/v1/buildinfo/urls.go b/openstack/orchestration/v1/buildinfo/urls.go
new file mode 100644
index 0000000..28a2128
--- /dev/null
+++ b/openstack/orchestration/v1/buildinfo/urls.go
@@ -0,0 +1,7 @@
+package buildinfo
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("build_info")
+}
diff --git a/openstack/orchestration/v1/stackevents/doc.go b/openstack/orchestration/v1/stackevents/doc.go
new file mode 100644
index 0000000..51cdd97
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/doc.go
@@ -0,0 +1,4 @@
+// Package stackevents provides operations for finding, listing, and retrieving
+// stack events. Stack events are events that take place on stacks such as
+// updating and abandoning.
+package stackevents
diff --git a/openstack/orchestration/v1/stackevents/fixtures.go b/openstack/orchestration/v1/stackevents/fixtures.go
new file mode 100644
index 0000000..48524e5
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/fixtures.go
@@ -0,0 +1,446 @@
+package stackevents
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []Event{
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_IN_PROGRESS",
+		PhysicalResourceID:   "",
+		ID:                   "06feb26f-9298-4a9b-8749-9d770e5d577a",
+	},
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_COMPLETE",
+		PhysicalResourceID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+		ID:                   "93940999-7d40-44ae-8de4-19624e7b8d18",
+	},
+}
+
+// FindOutput represents the response body from a Find request.
+const FindOutput = `
+{
+  "events": [
+  {
+    "resource_name": "hello_world",
+    "event_time": "2015-02-05T21:33:11",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "resource"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "resource_status": "CREATE_IN_PROGRESS",
+    "physical_resource_id": null,
+    "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+    },
+    {
+      "resource_name": "hello_world",
+      "event_time": "2015-02-05T21:33:27",
+      "links": [
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+        "rel": "self"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+        "rel": "resource"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+        "rel": "stack"
+      }
+      ],
+      "logical_resource_id": "hello_world",
+      "resource_status_reason": "state changed",
+      "resource_status": "CREATE_COMPLETE",
+      "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+      "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+    }
+  ]
+}`
+
+// HandleFindSuccessfully creates an HTTP handler at `/stacks/postman_stack/events`
+// on the test handler mux that responds with a `Find` response.
+func HandleFindSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/postman_stack/events", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []Event{
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_IN_PROGRESS",
+		PhysicalResourceID:   "",
+		ID:                   "06feb26f-9298-4a9b-8749-9d770e5d577a",
+	},
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_COMPLETE",
+		PhysicalResourceID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+		ID:                   "93940999-7d40-44ae-8de4-19624e7b8d18",
+	},
+}
+
+// ListOutput represents the response body from a List request.
+const ListOutput = `
+{
+  "events": [
+  {
+    "resource_name": "hello_world",
+    "event_time": "2015-02-05T21:33:11",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "resource"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "resource_status": "CREATE_IN_PROGRESS",
+    "physical_resource_id": null,
+    "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+    },
+    {
+      "resource_name": "hello_world",
+      "event_time": "2015-02-05T21:33:27",
+      "links": [
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+        "rel": "self"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+        "rel": "resource"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+        "rel": "stack"
+      }
+      ],
+      "logical_resource_id": "hello_world",
+      "resource_status_reason": "state changed",
+      "resource_status": "CREATE_COMPLETE",
+      "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+      "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+    }
+  ]
+}`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/events`
+// on the test handler mux that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/events", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, output)
+		case "93940999-7d40-44ae-8de4-19624e7b8d18":
+			fmt.Fprintf(w, `{"events":[]}`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// ListResourceEventsExpected represents the expected object from a ListResourceEvents request.
+var ListResourceEventsExpected = []Event{
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_IN_PROGRESS",
+		PhysicalResourceID:   "",
+		ID:                   "06feb26f-9298-4a9b-8749-9d770e5d577a",
+	},
+	Event{
+		ResourceName: "hello_world",
+		Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC)),
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "resource",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalResourceID:    "hello_world",
+		ResourceStatusReason: "state changed",
+		ResourceStatus:       "CREATE_COMPLETE",
+		PhysicalResourceID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+		ID:                   "93940999-7d40-44ae-8de4-19624e7b8d18",
+	},
+}
+
+// ListResourceEventsOutput represents the response body from a ListResourceEvents request.
+const ListResourceEventsOutput = `
+{
+  "events": [
+  {
+    "resource_name": "hello_world",
+    "event_time": "2015-02-05T21:33:11",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/06feb26f-9298-4a9b-8749-9d770e5d577a",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "resource"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "resource_status": "CREATE_IN_PROGRESS",
+    "physical_resource_id": null,
+    "id": "06feb26f-9298-4a9b-8749-9d770e5d577a"
+    },
+    {
+      "resource_name": "hello_world",
+      "event_time": "2015-02-05T21:33:27",
+      "links": [
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+        "rel": "self"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+        "rel": "resource"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+        "rel": "stack"
+      }
+      ],
+      "logical_resource_id": "hello_world",
+      "resource_status_reason": "state changed",
+      "resource_status": "CREATE_COMPLETE",
+      "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+      "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+    }
+  ]
+}`
+
+// HandleListResourceEventsSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events`
+// on the test handler mux that responds with a `ListResourceEvents` response.
+func HandleListResourceEventsSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, output)
+		case "93940999-7d40-44ae-8de4-19624e7b8d18":
+			fmt.Fprintf(w, `{"events":[]}`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &Event{
+	ResourceName: "hello_world",
+	Time:         gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 27, 0, time.UTC)),
+	Links: []gophercloud.Link{
+		gophercloud.Link{
+			Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+			Rel:  "self",
+		},
+		gophercloud.Link{
+			Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+			Rel:  "resource",
+		},
+		gophercloud.Link{
+			Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+			Rel:  "stack",
+		},
+	},
+	LogicalResourceID:    "hello_world",
+	ResourceStatusReason: "state changed",
+	ResourceStatus:       "CREATE_COMPLETE",
+	PhysicalResourceID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+	ID:                   "93940999-7d40-44ae-8de4-19624e7b8d18",
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+  "event":{
+    "resource_name": "hello_world",
+    "event_time": "2015-02-05T21:33:27",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world/events/93940999-7d40-44ae-8de4-19624e7b8d18",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "resource"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "resource_status": "CREATE_COMPLETE",
+    "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+    "id": "93940999-7d40-44ae-8de4-19624e7b8d18"
+  }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events/93940999-7d40-44ae-8de4-19624e7b8d18`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources/my_resource/events/93940999-7d40-44ae-8de4-19624e7b8d18", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
diff --git a/openstack/orchestration/v1/stackevents/requests.go b/openstack/orchestration/v1/stackevents/requests.go
new file mode 100644
index 0000000..e6e7f79
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/requests.go
@@ -0,0 +1,182 @@
+package stackevents
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Find retrieves stack events for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) (r FindResult) {
+	_, r.Err = c.Get(findURL(c, stackName), &r.Body, nil)
+	return
+}
+
+// SortDir is a type for specifying in which direction to sort a list of events.
+type SortDir string
+
+// SortKey is a type for specifying by which key to sort a list of events.
+type SortKey string
+
+// ResourceStatus is a type for specifying by which resource status to filter a
+// list of events.
+type ResourceStatus string
+
+// ResourceAction is a type for specifying by which resource action to filter a
+// list of events.
+type ResourceAction string
+
+var (
+	// ResourceStatusInProgress is used to filter a List request by the 'IN_PROGRESS' status.
+	ResourceStatusInProgress ResourceStatus = "IN_PROGRESS"
+	// ResourceStatusComplete is used to filter a List request by the 'COMPLETE' status.
+	ResourceStatusComplete ResourceStatus = "COMPLETE"
+	// ResourceStatusFailed is used to filter a List request by the 'FAILED' status.
+	ResourceStatusFailed ResourceStatus = "FAILED"
+
+	// ResourceActionCreate is used to filter a List request by the 'CREATE' action.
+	ResourceActionCreate ResourceAction = "CREATE"
+	// ResourceActionDelete is used to filter a List request by the 'DELETE' action.
+	ResourceActionDelete ResourceAction = "DELETE"
+	// ResourceActionUpdate is used to filter a List request by the 'UPDATE' action.
+	ResourceActionUpdate ResourceAction = "UPDATE"
+	// ResourceActionRollback is used to filter a List request by the 'ROLLBACK' action.
+	ResourceActionRollback ResourceAction = "ROLLBACK"
+	// ResourceActionSuspend is used to filter a List request by the 'SUSPEND' action.
+	ResourceActionSuspend ResourceAction = "SUSPEND"
+	// ResourceActionResume is used to filter a List request by the 'RESUME' action.
+	ResourceActionResume ResourceAction = "RESUME"
+	// ResourceActionAbandon is used to filter a List request by the 'ABANDON' action.
+	ResourceActionAbandon ResourceAction = "ABANDON"
+
+	// SortAsc is used to sort a list of stacks in ascending order.
+	SortAsc SortDir = "asc"
+	// SortDesc is used to sort a list of stacks in descending order.
+	SortDesc SortDir = "desc"
+
+	// SortName is used to sort a list of stacks by name.
+	SortName SortKey = "name"
+	// SortResourceType is used to sort a list of stacks by resource type.
+	SortResourceType SortKey = "resource_type"
+	// SortCreatedAt is used to sort a list of stacks by date created.
+	SortCreatedAt SortKey = "created_at"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToStackEventListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+	// The stack resource ID with which to start the listing.
+	Marker string `q:"marker"`
+	// Integer value for the limit of values to return.
+	Limit int `q:"limit"`
+	// Filters the event list by the specified ResourceAction. You can use this
+	// filter multiple times to filter by multiple resource actions: CREATE, DELETE,
+	// UPDATE, ROLLBACK, SUSPEND, RESUME or ADOPT.
+	ResourceActions []ResourceAction `q:"resource_action"`
+	// Filters the event list by the specified resource_status. You can use this
+	// filter multiple times to filter by multiple resource statuses: IN_PROGRESS,
+	// COMPLETE or FAILED.
+	ResourceStatuses []ResourceStatus `q:"resource_status"`
+	// Filters the event list by the specified resource_name. You can use this
+	// filter multiple times to filter by multiple resource names.
+	ResourceNames []string `q:"resource_name"`
+	// Filters the event list by the specified resource_type. You can use this
+	// filter multiple times to filter by multiple resource types: OS::Nova::Server,
+	// OS::Cinder::Volume, and so on.
+	ResourceTypes []string `q:"resource_type"`
+	// Sorts the event list by: resource_type or created_at.
+	SortKey SortKey `q:"sort_keys"`
+	// The sort direction of the event list. Which is asc (ascending) or desc (descending).
+	SortDir SortDir `q:"sort_dir"`
+}
+
+// ToStackEventListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackEventListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(client *gophercloud.ServiceClient, stackName, stackID string, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client, stackName, stackID)
+	if opts != nil {
+		query, err := opts.ToStackEventListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	})
+}
+
+// ListResourceEventsOptsBuilder allows extensions to add additional parameters to the
+// ListResourceEvents request.
+type ListResourceEventsOptsBuilder interface {
+	ToResourceEventListQuery() (string, error)
+}
+
+// ListResourceEventsOpts allows the filtering and sorting of paginated resource events through
+// the API. Marker and Limit are used for pagination.
+type ListResourceEventsOpts struct {
+	// The stack resource ID with which to start the listing.
+	Marker string `q:"marker"`
+	// Integer value for the limit of values to return.
+	Limit int `q:"limit"`
+	// Filters the event list by the specified ResourceAction. You can use this
+	// filter multiple times to filter by multiple resource actions: CREATE, DELETE,
+	// UPDATE, ROLLBACK, SUSPEND, RESUME or ADOPT.
+	ResourceActions []string `q:"resource_action"`
+	// Filters the event list by the specified resource_status. You can use this
+	// filter multiple times to filter by multiple resource statuses: IN_PROGRESS,
+	// COMPLETE or FAILED.
+	ResourceStatuses []string `q:"resource_status"`
+	// Filters the event list by the specified resource_name. You can use this
+	// filter multiple times to filter by multiple resource names.
+	ResourceNames []string `q:"resource_name"`
+	// Filters the event list by the specified resource_type. You can use this
+	// filter multiple times to filter by multiple resource types: OS::Nova::Server,
+	// OS::Cinder::Volume, and so on.
+	ResourceTypes []string `q:"resource_type"`
+	// Sorts the event list by: resource_type or created_at.
+	SortKey SortKey `q:"sort_keys"`
+	// The sort direction of the event list. Which is asc (ascending) or desc (descending).
+	SortDir SortDir `q:"sort_dir"`
+}
+
+// ToResourceEventListQuery formats a ListResourceEventsOpts into a query string.
+func (opts ListResourceEventsOpts) ToResourceEventListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// ListResourceEvents makes a request against the API to list resources for the given stack.
+func ListResourceEvents(client *gophercloud.ServiceClient, stackName, stackID, resourceName string, opts ListResourceEventsOptsBuilder) pagination.Pager {
+	url := listResourceEventsURL(client, stackName, stackID, resourceName)
+	if opts != nil {
+		query, err := opts.ToResourceEventListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		p := EventPage{pagination.MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	})
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, stackName, stackID, resourceName, eventID), &r.Body, nil)
+	return
+}
diff --git a/openstack/orchestration/v1/stackevents/requests_test.go b/openstack/orchestration/v1/stackevents/requests_test.go
new file mode 100644
index 0000000..cead1f3
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/requests_test.go
@@ -0,0 +1,71 @@
+package stackevents
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestFindEvents(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleFindSuccessfully(t, FindOutput)
+
+	actual, err := Find(fake.ServiceClient(), "postman_stack").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := FindExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t, ListOutput)
+
+	count := 0
+	err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractEvents(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ListExpected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestListResourceEvents(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListResourceEventsSuccessfully(t, ListResourceEventsOutput)
+
+	count := 0
+	err := ListResourceEvents(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", nil).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractEvents(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ListResourceEventsExpected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestGetEvent(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t, GetOutput)
+
+	actual, err := Get(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", "my_resource", "93940999-7d40-44ae-8de4-19624e7b8d18").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stackevents/results.go b/openstack/orchestration/v1/stackevents/results.go
new file mode 100644
index 0000000..6c7f183
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/results.go
@@ -0,0 +1,98 @@
+package stackevents
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Event represents a stack event.
+type Event struct {
+	// The name of the resource for which the event occurred.
+	ResourceName string `json:"resource_name"`
+	// The time the event occurred.
+	Time gophercloud.JSONRFC3339NoZ `json:"event_time"`
+	// The URLs to the event.
+	Links []gophercloud.Link `json:"links"`
+	// The logical ID of the stack resource.
+	LogicalResourceID string `json:"logical_resource_id"`
+	// The reason of the status of the event.
+	ResourceStatusReason string `json:"resource_status_reason"`
+	// The status of the event.
+	ResourceStatus string `json:"resource_status"`
+	// The physical ID of the stack resource.
+	PhysicalResourceID string `json:"physical_resource_id"`
+	// The event ID.
+	ID string `json:"id"`
+	// Properties of the stack resource.
+	ResourceProperties map[string]interface{} `json:"resource_properties"`
+}
+
+// FindResult represents the result of a Find operation.
+type FindResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a slice of Event objects and is called after a
+// Find operation.
+func (r FindResult) Extract() ([]Event, error) {
+	var s struct {
+		Events []Event `json:"events"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Events, err
+}
+
+// EventPage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResources call.
+type EventPage struct {
+	pagination.MarkerPageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (r EventPage) IsEmpty() (bool, error) {
+	events, err := ExtractEvents(r)
+	return len(events) == 0, err
+}
+
+// LastMarker returns the last stack ID in a ListResult.
+func (r EventPage) LastMarker() (string, error) {
+	events, err := ExtractEvents(r)
+	if err != nil {
+		return "", err
+	}
+	if len(events) == 0 {
+		return "", nil
+	}
+	return events[len(events)-1].ID, nil
+}
+
+// ExtractEvents interprets the results of a single page from a List() call, producing a slice of Event entities.
+func ExtractEvents(r pagination.Page) ([]Event, error) {
+	var s struct {
+		Events []Event `json:"events"`
+	}
+	err := (r.(EventPage)).ExtractInto(&s)
+	return s.Events, err
+}
+
+// ExtractResourceEvents interprets the results of a single page from a
+// ListResourceEvents() call, producing a slice of Event entities.
+func ExtractResourceEvents(page pagination.Page) ([]Event, error) {
+	return ExtractEvents(page)
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to an Event object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*Event, error) {
+	var s struct {
+		Event *Event `json:"event"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Event, err
+}
diff --git a/openstack/orchestration/v1/stackevents/urls.go b/openstack/orchestration/v1/stackevents/urls.go
new file mode 100644
index 0000000..6b6b330
--- /dev/null
+++ b/openstack/orchestration/v1/stackevents/urls.go
@@ -0,0 +1,19 @@
+package stackevents
+
+import "github.com/gophercloud/gophercloud"
+
+func findURL(c *gophercloud.ServiceClient, stackName string) string {
+	return c.ServiceURL("stacks", stackName, "events")
+}
+
+func listURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "events")
+}
+
+func listResourceEventsURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "events")
+}
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName, eventID string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "events", eventID)
+}
diff --git a/openstack/orchestration/v1/stackresources/doc.go b/openstack/orchestration/v1/stackresources/doc.go
new file mode 100644
index 0000000..e4f8b08
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/doc.go
@@ -0,0 +1,5 @@
+// Package stackresources provides operations for working with stack resources.
+// A resource is a template artifact that represents some component of your
+// desired architecture (a Cloud Server, a group of scaled Cloud Servers, a load
+// balancer, some configuration management system, and so forth).
+package stackresources
diff --git a/openstack/orchestration/v1/stackresources/fixtures.go b/openstack/orchestration/v1/stackresources/fixtures.go
new file mode 100644
index 0000000..a622f7f
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/fixtures.go
@@ -0,0 +1,439 @@
+package stackresources
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// FindExpected represents the expected object from a Find request.
+var FindExpected = []Resource{
+	Resource{
+		Name: "hello_world",
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalID:    "hello_world",
+		StatusReason: "state changed",
+		UpdatedTime:  gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC)),
+		CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 10, 0, time.UTC)),
+		RequiredBy:   []interface{}{},
+		Status:       "CREATE_IN_PROGRESS",
+		PhysicalID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+		Type:         "OS::Nova::Server",
+		Attributes:   map[string]interface{}{"SXSW": "atx"},
+		Description:  "Some resource",
+	},
+}
+
+// FindOutput represents the response body from a Find request.
+const FindOutput = `
+{
+  "resources": [
+  {
+  	"description": "Some resource",
+  	"attributes": {"SXSW": "atx"},
+    "resource_name": "hello_world",
+    "links": [
+      {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "self"
+      },
+      {
+        "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+        "rel": "stack"
+      }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "updated_time": "2015-02-05T21:33:11",
+	"creation_time": "2015-02-05T21:33:10",
+    "required_by": [],
+    "resource_status": "CREATE_IN_PROGRESS",
+    "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+    "resource_type": "OS::Nova::Server"
+  }
+  ]
+}`
+
+// HandleFindSuccessfully creates an HTTP handler at `/stacks/hello_world/resources`
+// on the test handler mux that responds with a `Find` response.
+func HandleFindSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/hello_world/resources", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []Resource{
+	Resource{
+		Name: "hello_world",
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+				Rel:  "self",
+			},
+			gophercloud.Link{
+				Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+				Rel:  "stack",
+			},
+		},
+		LogicalID:    "hello_world",
+		StatusReason: "state changed",
+		UpdatedTime:  gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 11, 0, time.UTC)),
+		CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 5, 21, 33, 10, 0, time.UTC)),
+		RequiredBy:   []interface{}{},
+		Status:       "CREATE_IN_PROGRESS",
+		PhysicalID:   "49181cd6-169a-4130-9455-31185bbfc5bf",
+		Type:         "OS::Nova::Server",
+		Attributes:   map[string]interface{}{"SXSW": "atx"},
+		Description:  "Some resource",
+	},
+}
+
+// ListOutput represents the response body from a List request.
+const ListOutput = `{
+  "resources": [
+  {
+    "resource_name": "hello_world",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b/resources/hello_world",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/5f57cff9-93fc-424e-9f78-df0515e7f48b",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "hello_world",
+    "resource_status_reason": "state changed",
+    "updated_time": "2015-02-05T21:33:11",
+    "required_by": [],
+    "resource_status": "CREATE_IN_PROGRESS",
+    "physical_resource_id": "49181cd6-169a-4130-9455-31185bbfc5bf",
+	"creation_time": "2015-02-05T21:33:10",
+    "resource_type": "OS::Nova::Server",
+	"attributes": {"SXSW": "atx"},
+	"description": "Some resource"
+  }
+]
+}`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources`
+// on the test handler mux that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/hello_world/49181cd6-169a-4130-9455-31185bbfc5bf/resources", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, output)
+		case "49181cd6-169a-4130-9455-31185bbfc5bf":
+			fmt.Fprintf(w, `{"resources":[]}`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &Resource{
+	Name: "wordpress_instance",
+	Links: []gophercloud.Link{
+		gophercloud.Link{
+			Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance",
+			Rel:  "self",
+		},
+		gophercloud.Link{
+			Href: "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e",
+			Rel:  "stack",
+		},
+	},
+	LogicalID:    "wordpress_instance",
+	Attributes:   map[string]interface{}{"SXSW": "atx"},
+	StatusReason: "state changed",
+	UpdatedTime:  gophercloud.JSONRFC3339NoZ(time.Date(2014, 12, 10, 18, 34, 35, 0, time.UTC)),
+	RequiredBy:   []interface{}{},
+	Status:       "CREATE_COMPLETE",
+	PhysicalID:   "00e3a2fe-c65d-403c-9483-4db9930dd194",
+	Type:         "OS::Nova::Server",
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+  "resource": {
+    "description": "Some resource",
+    "attributes": {"SXSW": "atx"},
+    "resource_name": "wordpress_instance",
+    "description": "",
+    "links": [
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance",
+      "rel": "self"
+    },
+    {
+      "href": "http://166.78.160.107:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e",
+      "rel": "stack"
+    }
+    ],
+    "logical_resource_id": "wordpress_instance",
+    "resource_status": "CREATE_COMPLETE",
+    "updated_time": "2014-12-10T18:34:35",
+    "required_by": [],
+    "resource_status_reason": "state changed",
+    "physical_resource_id": "00e3a2fe-c65d-403c-9483-4db9930dd194",
+    "resource_type": "OS::Nova::Server"
+  }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// MetadataExpected represents the expected object from a Metadata request.
+var MetadataExpected = map[string]string{
+	"number": "7",
+	"animal": "auk",
+}
+
+// MetadataOutput represents the response body from a Metadata request.
+const MetadataOutput = `
+{
+    "metadata": {
+      "number": "7",
+      "animal": "auk"
+    }
+}`
+
+// HandleMetadataSuccessfully creates an HTTP handler at `/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance/metadata`
+// on the test handler mux that responds with a `Metadata` response.
+func HandleMetadataSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/teststack/0b1771bd-9336-4f2b-ae86-a80f971faf1e/resources/wordpress_instance/metadata", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ListTypesExpected represents the expected object from a ListTypes request.
+var ListTypesExpected = ResourceTypes{
+	"OS::Nova::Server",
+	"OS::Heat::RandomString",
+	"OS::Swift::Container",
+	"OS::Trove::Instance",
+	"OS::Nova::FloatingIPAssociation",
+	"OS::Cinder::VolumeAttachment",
+	"OS::Nova::FloatingIP",
+	"OS::Nova::KeyPair",
+}
+
+// same as above, but sorted
+var SortedListTypesExpected = ResourceTypes{
+	"OS::Cinder::VolumeAttachment",
+	"OS::Heat::RandomString",
+	"OS::Nova::FloatingIP",
+	"OS::Nova::FloatingIPAssociation",
+	"OS::Nova::KeyPair",
+	"OS::Nova::Server",
+	"OS::Swift::Container",
+	"OS::Trove::Instance",
+}
+
+// ListTypesOutput represents the response body from a ListTypes request.
+const ListTypesOutput = `
+{
+  "resource_types": [
+    "OS::Nova::Server",
+    "OS::Heat::RandomString",
+    "OS::Swift::Container",
+    "OS::Trove::Instance",
+    "OS::Nova::FloatingIPAssociation",
+    "OS::Cinder::VolumeAttachment",
+    "OS::Nova::FloatingIP",
+    "OS::Nova::KeyPair"
+  ]
+}`
+
+// HandleListTypesSuccessfully creates an HTTP handler at `/resource_types`
+// on the test handler mux that responds with a `ListTypes` response.
+func HandleListTypesSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/resource_types", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// GetSchemaExpected represents the expected object from a Schema request.
+var GetSchemaExpected = &TypeSchema{
+	Attributes: map[string]interface{}{
+		"an_attribute": map[string]interface{}{
+			"description": "An attribute description .",
+		},
+	},
+	Properties: map[string]interface{}{
+		"a_property": map[string]interface{}{
+			"update_allowed": false,
+			"required":       true,
+			"type":           "string",
+			"description":    "A resource description.",
+		},
+	},
+	ResourceType: "OS::Heat::AResourceName",
+	SupportStatus: map[string]interface{}{
+		"message": "A status message",
+		"status":  "SUPPORTED",
+		"version": "2014.1",
+	},
+}
+
+// GetSchemaOutput represents the response body from a Schema request.
+const GetSchemaOutput = `
+{
+  "attributes": {
+    "an_attribute": {
+      "description": "An attribute description ."
+    }
+  },
+  "properties": {
+    "a_property": {
+      "update_allowed": false,
+      "required": true,
+      "type": "string",
+      "description": "A resource description."
+    }
+  },
+  "resource_type": "OS::Heat::AResourceName",
+  "support_status": {
+	"message": "A status message",
+	"status": "SUPPORTED",
+	"version": "2014.1"
+  }
+}`
+
+// HandleGetSchemaSuccessfully creates an HTTP handler at `/resource_types/OS::Heat::AResourceName`
+// on the test handler mux that responds with a `Schema` response.
+func HandleGetSchemaSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/resource_types/OS::Heat::AResourceName", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// GetTemplateExpected represents the expected object from a Template request.
+var GetTemplateExpected = "{\n  \"HeatTemplateFormatVersion\": \"2012-12-12\",\n  \"Outputs\": {\n    \"private_key\": {\n      \"Description\": \"The private key if it has been saved.\",\n      \"Value\": \"{\\\"Fn::GetAtt\\\": [\\\"KeyPair\\\", \\\"private_key\\\"]}\"\n    },\n    \"public_key\": {\n      \"Description\": \"The public key.\",\n      \"Value\": \"{\\\"Fn::GetAtt\\\": [\\\"KeyPair\\\", \\\"public_key\\\"]}\"\n    }\n  },\n  \"Parameters\": {\n    \"name\": {\n      \"Description\": \"The name of the key pair.\",\n      \"Type\": \"String\"\n    },\n    \"public_key\": {\n      \"Description\": \"The optional public key. This allows users to supply the public key from a pre-existing key pair. If not supplied, a new key pair will be generated.\",\n      \"Type\": \"String\"\n    },\n    \"save_private_key\": {\n      \"AllowedValues\": [\n        \"True\",\n        \"true\",\n        \"False\",\n        \"false\"\n      ],\n      \"Default\": false,\n      \"Description\": \"True if the system should remember a generated private key; False otherwise.\",\n      \"Type\": \"String\"\n    }\n  },\n  \"Resources\": {\n    \"KeyPair\": {\n      \"Properties\": {\n        \"name\": {\n          \"Ref\": \"name\"\n        },\n        \"public_key\": {\n          \"Ref\": \"public_key\"\n        },\n        \"save_private_key\": {\n          \"Ref\": \"save_private_key\"\n        }\n      },\n      \"Type\": \"OS::Nova::KeyPair\"\n    }\n  }\n}"
+
+// GetTemplateOutput represents the response body from a Template request.
+const GetTemplateOutput = `
+{
+  "HeatTemplateFormatVersion": "2012-12-12",
+  "Outputs": {
+    "private_key": {
+      "Description": "The private key if it has been saved.",
+      "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"private_key\"]}"
+    },
+    "public_key": {
+      "Description": "The public key.",
+      "Value": "{\"Fn::GetAtt\": [\"KeyPair\", \"public_key\"]}"
+    }
+  },
+  "Parameters": {
+    "name": {
+      "Description": "The name of the key pair.",
+      "Type": "String"
+    },
+    "public_key": {
+      "Description": "The optional public key. This allows users to supply the public key from a pre-existing key pair. If not supplied, a new key pair will be generated.",
+      "Type": "String"
+    },
+    "save_private_key": {
+      "AllowedValues": [
+      "True",
+      "true",
+      "False",
+      "false"
+      ],
+      "Default": false,
+      "Description": "True if the system should remember a generated private key; False otherwise.",
+      "Type": "String"
+    }
+  },
+  "Resources": {
+    "KeyPair": {
+      "Properties": {
+        "name": {
+          "Ref": "name"
+        },
+        "public_key": {
+          "Ref": "public_key"
+        },
+        "save_private_key": {
+          "Ref": "save_private_key"
+        }
+      },
+      "Type": "OS::Nova::KeyPair"
+    }
+  }
+}`
+
+// HandleGetTemplateSuccessfully creates an HTTP handler at `/resource_types/OS::Heat::AResourceName/template`
+// on the test handler mux that responds with a `Template` response.
+func HandleGetTemplateSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/resource_types/OS::Heat::AResourceName/template", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
diff --git a/openstack/orchestration/v1/stackresources/requests.go b/openstack/orchestration/v1/stackresources/requests.go
new file mode 100644
index 0000000..f368b76
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/requests.go
@@ -0,0 +1,77 @@
+package stackresources
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Find retrieves stack resources for the given stack name.
+func Find(c *gophercloud.ServiceClient, stackName string) (r FindResult) {
+	_, r.Err = c.Get(findURL(c, stackName), &r.Body, nil)
+	return
+}
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToStackResourceListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Marker and Limit are used for pagination.
+type ListOpts struct {
+	// Include resources from nest stacks up to Depth levels of recursion.
+	Depth int `q:"nested_depth"`
+}
+
+// ToStackResourceListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackResourceListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	return q.String(), err
+}
+
+// List makes a request against the API to list resources for the given stack.
+func List(client *gophercloud.ServiceClient, stackName, stackID string, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(client, stackName, stackID)
+	if opts != nil {
+		query, err := opts.ToStackResourceListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	return pagination.NewPager(client, url, func(r pagination.PageResult) pagination.Page {
+		return ResourcePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Get retreives data for the given stack resource.
+func Get(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, stackName, stackID, resourceName), &r.Body, nil)
+	return
+}
+
+// Metadata retreives the metadata for the given stack resource.
+func Metadata(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) (r MetadataResult) {
+	_, r.Err = c.Get(metadataURL(c, stackName, stackID, resourceName), &r.Body, nil)
+	return
+}
+
+// ListTypes makes a request against the API to list resource types.
+func ListTypes(client *gophercloud.ServiceClient) pagination.Pager {
+	return pagination.NewPager(client, listTypesURL(client), func(r pagination.PageResult) pagination.Page {
+		return ResourceTypePage{pagination.SinglePageBase(r)}
+	})
+}
+
+// Schema retreives the schema for the given resource type.
+func Schema(c *gophercloud.ServiceClient, resourceType string) (r SchemaResult) {
+	_, r.Err = c.Get(schemaURL(c, resourceType), &r.Body, nil)
+	return
+}
+
+// Template retreives the template representation for the given resource type.
+func Template(c *gophercloud.ServiceClient, resourceType string) (r TemplateResult) {
+	_, r.Err = c.Get(templateURL(c, resourceType), &r.Body, nil)
+	return
+}
diff --git a/openstack/orchestration/v1/stackresources/requests_test.go b/openstack/orchestration/v1/stackresources/requests_test.go
new file mode 100644
index 0000000..7932873
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/requests_test.go
@@ -0,0 +1,111 @@
+package stackresources
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestFindResources(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleFindSuccessfully(t, FindOutput)
+
+	actual, err := Find(fake.ServiceClient(), "hello_world").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := FindExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResources(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t, ListOutput)
+
+	count := 0
+	err := List(fake.ServiceClient(), "hello_world", "49181cd6-169a-4130-9455-31185bbfc5bf", nil).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractResources(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ListExpected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestGetResource(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t, GetOutput)
+
+	actual, err := Get(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestResourceMetadata(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleMetadataSuccessfully(t, MetadataOutput)
+
+	actual, err := Metadata(fake.ServiceClient(), "teststack", "0b1771bd-9336-4f2b-ae86-a80f971faf1e", "wordpress_instance").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := MetadataExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListResourceTypes(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListTypesSuccessfully(t, ListTypesOutput)
+
+	count := 0
+	err := ListTypes(fake.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractResourceTypes(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ListTypesExpected, actual)
+		// test if sorting works
+		sort.Sort(actual)
+		th.CheckDeepEquals(t, SortedListTypesExpected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, 1, count)
+}
+
+func TestGetResourceSchema(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSchemaSuccessfully(t, GetSchemaOutput)
+
+	actual, err := Schema(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetSchemaExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestGetResourceTemplate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetTemplateSuccessfully(t, GetTemplateOutput)
+
+	actual, err := Template(fake.ServiceClient(), "OS::Heat::AResourceName").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetTemplateExpected
+	th.AssertDeepEquals(t, expected, string(actual))
+}
diff --git a/openstack/orchestration/v1/stackresources/results.go b/openstack/orchestration/v1/stackresources/results.go
new file mode 100644
index 0000000..bd3e29f
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/results.go
@@ -0,0 +1,165 @@
+package stackresources
+
+import (
+	"encoding/json"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// Resource represents a stack resource.
+type Resource struct {
+	Attributes   map[string]interface{}     `json:"attributes"`
+	CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+	Description  string                     `json:"description"`
+	Links        []gophercloud.Link         `json:"links"`
+	LogicalID    string                     `json:"logical_resource_id"`
+	Name         string                     `json:"resource_name"`
+	PhysicalID   string                     `json:"physical_resource_id"`
+	RequiredBy   []interface{}              `json:"required_by"`
+	Status       string                     `json:"resource_status"`
+	StatusReason string                     `json:"resource_status_reason"`
+	Type         string                     `json:"resource_type"`
+	UpdatedTime  gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+}
+
+// FindResult represents the result of a Find operation.
+type FindResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a slice of Resource objects and is called after a
+// Find operation.
+func (r FindResult) Extract() ([]Resource, error) {
+	var s struct {
+		Resources []Resource `json:"resources"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Resources, err
+}
+
+// ResourcePage abstracts the raw results of making a List() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResources call.
+type ResourcePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a page contains no Server results.
+func (r ResourcePage) IsEmpty() (bool, error) {
+	resources, err := ExtractResources(r)
+	return len(resources) == 0, err
+}
+
+// ExtractResources interprets the results of a single page from a List() call, producing a slice of Resource entities.
+func ExtractResources(r pagination.Page) ([]Resource, error) {
+	var s struct {
+		Resources []Resource `json:"resources"`
+	}
+	err := (r.(ResourcePage)).ExtractInto(&s)
+	return s.Resources, err
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a Resource object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*Resource, error) {
+	var s struct {
+		Resource *Resource `json:"resource"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Resource, err
+}
+
+// MetadataResult represents the result of a Metadata operation.
+type MetadataResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a map object and is called after a
+// Metadata operation.
+func (r MetadataResult) Extract() (map[string]string, error) {
+	var s struct {
+		Meta map[string]string `json:"metadata"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Meta, err
+}
+
+// ResourceTypePage abstracts the raw results of making a ListTypes() request against the API.
+// As OpenStack extensions may freely alter the response bodies of structures returned to the client, you may only safely access the
+// data provided through the ExtractResourceTypes call.
+type ResourceTypePage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ResourceTypePage contains no resource types.
+func (r ResourceTypePage) IsEmpty() (bool, error) {
+	rts, err := ExtractResourceTypes(r)
+	return len(rts) == 0, err
+}
+
+// ResourceTypes represents the type that holds the result of ExtractResourceTypes.
+// We define methods on this type to sort it before output
+type ResourceTypes []string
+
+func (r ResourceTypes) Len() int {
+	return len(r)
+}
+
+func (r ResourceTypes) Swap(i, j int) {
+	r[i], r[j] = r[j], r[i]
+}
+
+func (r ResourceTypes) Less(i, j int) bool {
+	return r[i] < r[j]
+}
+
+// ExtractResourceTypes extracts and returns resource types.
+func ExtractResourceTypes(r pagination.Page) (ResourceTypes, error) {
+	var s struct {
+		ResourceTypes ResourceTypes `json:"resource_types"`
+	}
+	err := (r.(ResourceTypePage)).ExtractInto(&s)
+	return s.ResourceTypes, err
+}
+
+// TypeSchema represents a stack resource schema.
+type TypeSchema struct {
+	Attributes    map[string]interface{} `json:"attributes"`
+	Properties    map[string]interface{} `json:"properties"`
+	ResourceType  string                 `json:"resource_type"`
+	SupportStatus map[string]interface{} `json:"support_status"`
+}
+
+// SchemaResult represents the result of a Schema operation.
+type SchemaResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a TypeSchema object and is called after a
+// Schema operation.
+func (r SchemaResult) Extract() (*TypeSchema, error) {
+	var s *TypeSchema
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// TemplateResult represents the result of a Template operation.
+type TemplateResult struct {
+	gophercloud.Result
+}
+
+// Extract returns the template and is called after a
+// Template operation.
+func (r TemplateResult) Extract() ([]byte, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	template, err := json.MarshalIndent(r.Body, "", "  ")
+	return template, err
+}
diff --git a/openstack/orchestration/v1/stackresources/urls.go b/openstack/orchestration/v1/stackresources/urls.go
new file mode 100644
index 0000000..bbddc69
--- /dev/null
+++ b/openstack/orchestration/v1/stackresources/urls.go
@@ -0,0 +1,31 @@
+package stackresources
+
+import "github.com/gophercloud/gophercloud"
+
+func findURL(c *gophercloud.ServiceClient, stackName string) string {
+	return c.ServiceURL("stacks", stackName, "resources")
+}
+
+func listURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "resources")
+}
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName)
+}
+
+func metadataURL(c *gophercloud.ServiceClient, stackName, stackID, resourceName string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "resources", resourceName, "metadata")
+}
+
+func listTypesURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("resource_types")
+}
+
+func schemaURL(c *gophercloud.ServiceClient, typeName string) string {
+	return c.ServiceURL("resource_types", typeName)
+}
+
+func templateURL(c *gophercloud.ServiceClient, typeName string) string {
+	return c.ServiceURL("resource_types", typeName, "template")
+}
diff --git a/openstack/orchestration/v1/stacks/doc.go b/openstack/orchestration/v1/stacks/doc.go
new file mode 100644
index 0000000..19231b5
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/doc.go
@@ -0,0 +1,8 @@
+// Package stacks provides operation for working with Heat stacks. A stack is a
+// group of resources (servers, load balancers, databases, and so forth)
+// combined to fulfill a useful purpose. Based on a template, Heat orchestration
+// engine creates an instantiated set of resources (a stack) to run the
+// application framework or component specified (in the template). A stack is a
+// running instance of a template. The result of creating a stack is a deployment
+// of the application framework or component.
+package stacks
diff --git a/openstack/orchestration/v1/stacks/environment.go b/openstack/orchestration/v1/stacks/environment.go
new file mode 100644
index 0000000..8698918
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment.go
@@ -0,0 +1,134 @@
+package stacks
+
+import "strings"
+
+// Environment is a structure that represents stack environments
+type Environment struct {
+	TE
+}
+
+// EnvironmentSections is a map containing allowed sections in a stack environment file
+var EnvironmentSections = map[string]bool{
+	"parameters":         true,
+	"parameter_defaults": true,
+	"resource_registry":  true,
+}
+
+// Validate validates the contents of the Environment
+func (e *Environment) Validate() error {
+	if e.Parsed == nil {
+		if err := e.Parse(); err != nil {
+			return err
+		}
+	}
+	for key := range e.Parsed {
+		if _, ok := EnvironmentSections[key]; !ok {
+			return ErrInvalidEnvironment{Section: key}
+		}
+	}
+	return nil
+}
+
+// Parse environment file to resolve the URL's of the resources. This is done by
+// reading from the `Resource Registry` section, which is why the function is
+// named GetRRFileContents.
+func (e *Environment) getRRFileContents(ignoreIf igFunc) error {
+	// initialize environment if empty
+	if e.Files == nil {
+		e.Files = make(map[string]string)
+	}
+	if e.fileMaps == nil {
+		e.fileMaps = make(map[string]string)
+	}
+
+	// get the resource registry
+	rr := e.Parsed["resource_registry"]
+
+	// search the resource registry for URLs
+	switch rr.(type) {
+	// process further only if the resource registry is a map
+	case map[string]interface{}, map[interface{}]interface{}:
+		rrMap, err := toStringKeys(rr)
+		if err != nil {
+			return err
+		}
+		// the resource registry might contain a base URL for the resource. If
+		// such a field is present, use it. Otherwise, use the default base URL.
+		var baseURL string
+		if val, ok := rrMap["base_url"]; ok {
+			baseURL = val.(string)
+		} else {
+			baseURL = e.baseURL
+		}
+
+		// The contents of the resource may be located in a remote file, which
+		// will be a template. Instantiate a temporary template to manage the
+		// contents.
+		tempTemplate := new(Template)
+		tempTemplate.baseURL = baseURL
+		tempTemplate.client = e.client
+
+		// Fetch the contents of remote resource URL's
+		if err = tempTemplate.getFileContents(rr, ignoreIf, false); err != nil {
+			return err
+		}
+		// check the `resources` section (if it exists) for more URL's. Note that
+		// the previous call to GetFileContents was (deliberately) not recursive
+		// as we want more control over where to look for URL's
+		if val, ok := rrMap["resources"]; ok {
+			switch val.(type) {
+			// process further only if the contents are a map
+			case map[string]interface{}, map[interface{}]interface{}:
+				resourcesMap, err := toStringKeys(val)
+				if err != nil {
+					return err
+				}
+				for _, v := range resourcesMap {
+					switch v.(type) {
+					case map[string]interface{}, map[interface{}]interface{}:
+						resourceMap, err := toStringKeys(v)
+						if err != nil {
+							return err
+						}
+						var resourceBaseURL string
+						// if base_url for the resource type is defined, use it
+						if val, ok := resourceMap["base_url"]; ok {
+							resourceBaseURL = val.(string)
+						} else {
+							resourceBaseURL = baseURL
+						}
+						tempTemplate.baseURL = resourceBaseURL
+						if err := tempTemplate.getFileContents(v, ignoreIf, false); err != nil {
+							return err
+						}
+					}
+				}
+			}
+		}
+		// if the resource registry contained any URL's, store them. This can
+		// then be passed as parameter to api calls to Heat api.
+		e.Files = tempTemplate.Files
+		return nil
+	default:
+		return nil
+	}
+}
+
+// function to choose keys whose values are other environment files
+func ignoreIfEnvironment(key string, value interface{}) bool {
+	// base_url and hooks refer to components which cannot have urls
+	if key == "base_url" || key == "hooks" {
+		return true
+	}
+	// if value is not string, it cannot be a URL
+	valueString, ok := value.(string)
+	if !ok {
+		return true
+	}
+	// if value contains `::`, it must be a reference to another resource type
+	// e.g. OS::Nova::Server : Rackspace::Cloud::Server
+	if strings.Contains(valueString, "::") {
+		return true
+	}
+	return false
+}
diff --git a/openstack/orchestration/v1/stacks/environment_test.go b/openstack/orchestration/v1/stacks/environment_test.go
new file mode 100644
index 0000000..5f8a430
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/environment_test.go
@@ -0,0 +1,184 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestEnvironmentValidation(t *testing.T) {
+	environmentJSON := new(Environment)
+	environmentJSON.Bin = []byte(ValidJSONEnvironment)
+	err := environmentJSON.Validate()
+	th.AssertNoErr(t, err)
+
+	environmentYAML := new(Environment)
+	environmentYAML.Bin = []byte(ValidYAMLEnvironment)
+	err = environmentYAML.Validate()
+	th.AssertNoErr(t, err)
+
+	environmentInvalid := new(Environment)
+	environmentInvalid.Bin = []byte(InvalidEnvironment)
+	if err = environmentInvalid.Validate(); err == nil {
+		t.Error("environment validation did not catch invalid environment")
+	}
+}
+
+func TestEnvironmentParsing(t *testing.T) {
+	environmentJSON := new(Environment)
+	environmentJSON.Bin = []byte(ValidJSONEnvironment)
+	err := environmentJSON.Parse()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, ValidJSONEnvironmentParsed, environmentJSON.Parsed)
+
+	environmentYAML := new(Environment)
+	environmentYAML.Bin = []byte(ValidJSONEnvironment)
+	err = environmentYAML.Parse()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, ValidJSONEnvironmentParsed, environmentYAML.Parsed)
+
+	environmentInvalid := new(Environment)
+	environmentInvalid.Bin = []byte("Keep Austin Weird")
+	err = environmentInvalid.Parse()
+	if err == nil {
+		t.Error("environment parsing did not catch invalid environment")
+	}
+}
+
+func TestIgnoreIfEnvironment(t *testing.T) {
+	var keyValueTests = []struct {
+		key   string
+		value interface{}
+		out   bool
+	}{
+		{"base_url", "afksdf", true},
+		{"not_type", "hooks", false},
+		{"get_file", "::", true},
+		{"hooks", "dfsdfsd", true},
+		{"type", "sdfubsduf.yaml", false},
+		{"type", "sdfsdufs.environment", false},
+		{"type", "sdfsdf.file", false},
+		{"type", map[string]string{"key": "value"}, true},
+	}
+	var result bool
+	for _, kv := range keyValueTests {
+		result = ignoreIfEnvironment(kv.key, kv.value)
+		if result != kv.out {
+			t.Errorf("key: %v, value: %v expected: %v, actual: %v", kv.key, kv.value, kv.out, result)
+		}
+	}
+}
+
+func TestGetRRFileContents(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	environmentContent := `
+heat_template_version: 2013-05-23
+
+description:
+  Heat WordPress template to support F18, using only Heat OpenStack-native
+  resource types, and without the requirement for heat-cfntools in the image.
+  WordPress is web software you can use to create a beautiful website or blog.
+  This template installs a single-instance WordPress deployment using a local
+  MySQL database to store the data.
+
+parameters:
+
+  key_name:
+    type: string
+    description : Name of a KeyPair to enable SSH access to the instance
+
+resources:
+  wordpress_instance:
+    type: OS::Nova::Server
+    properties:
+      image: { get_param: image_id }
+      flavor: { get_param: instance_type }
+      key_name: { get_param: key_name }`
+
+	dbContent := `
+heat_template_version: 2014-10-16
+
+description:
+  Test template for Trove resource capabilities
+
+parameters:
+  db_pass:
+    type: string
+    hidden: true
+    description: Database access password
+    default: secrete
+
+resources:
+
+service_db:
+  type: OS::Trove::Instance
+  properties:
+    name: trove_test_db
+    datastore_type: mariadb
+    flavor: 1GB Instance
+    size: 10
+    databases:
+    - name: test_data
+    users:
+    - name: kitchen_sink
+      password: { get_param: db_pass }
+      databases: [ test_data ]`
+	baseurl, err := getBasePath()
+	th.AssertNoErr(t, err)
+
+	fakeEnvURL := strings.Join([]string{baseurl, "my_env.yaml"}, "/")
+	urlparsed, err := url.Parse(fakeEnvURL)
+	th.AssertNoErr(t, err)
+	// handler for my_env.yaml
+	th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		w.Header().Set("Content-Type", "application/jason")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, environmentContent)
+	})
+
+	fakeDBURL := strings.Join([]string{baseurl, "my_db.yaml"}, "/")
+	urlparsed, err = url.Parse(fakeDBURL)
+	th.AssertNoErr(t, err)
+
+	// handler for my_db.yaml
+	th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		w.Header().Set("Content-Type", "application/jason")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, dbContent)
+	})
+
+	client := fakeClient{BaseClient: getHTTPClient()}
+	env := new(Environment)
+	env.Bin = []byte(`{"resource_registry": {"My::WP::Server": "my_env.yaml", "resources": {"my_db_server": {"OS::DBInstance": "my_db.yaml"}}}}`)
+	env.client = client
+
+	err = env.Parse()
+	th.AssertNoErr(t, err)
+	err = env.getRRFileContents(ignoreIfEnvironment)
+	th.AssertNoErr(t, err)
+	expectedEnvFilesContent := "\nheat_template_version: 2013-05-23\n\ndescription:\n  Heat WordPress template to support F18, using only Heat OpenStack-native\n  resource types, and without the requirement for heat-cfntools in the image.\n  WordPress is web software you can use to create a beautiful website or blog.\n  This template installs a single-instance WordPress deployment using a local\n  MySQL database to store the data.\n\nparameters:\n\n  key_name:\n    type: string\n    description : Name of a KeyPair to enable SSH access to the instance\n\nresources:\n  wordpress_instance:\n    type: OS::Nova::Server\n    properties:\n      image: { get_param: image_id }\n      flavor: { get_param: instance_type }\n      key_name: { get_param: key_name }"
+	expectedDBFilesContent := "\nheat_template_version: 2014-10-16\n\ndescription:\n  Test template for Trove resource capabilities\n\nparameters:\n  db_pass:\n    type: string\n    hidden: true\n    description: Database access password\n    default: secrete\n\nresources:\n\nservice_db:\n  type: OS::Trove::Instance\n  properties:\n    name: trove_test_db\n    datastore_type: mariadb\n    flavor: 1GB Instance\n    size: 10\n    databases:\n    - name: test_data\n    users:\n    - name: kitchen_sink\n      password: { get_param: db_pass }\n      databases: [ test_data ]"
+
+	th.AssertEquals(t, expectedEnvFilesContent, env.Files[fakeEnvURL])
+	th.AssertEquals(t, expectedDBFilesContent, env.Files[fakeDBURL])
+
+	env.fixFileRefs()
+	expectedParsed := map[string]interface{}{
+		"resource_registry": "2015-04-30",
+		"My::WP::Server":    fakeEnvURL,
+		"resources": map[string]interface{}{
+			"my_db_server": map[string]interface{}{
+				"OS::DBInstance": fakeDBURL,
+			},
+		},
+	}
+	env.Parse()
+	th.AssertDeepEquals(t, expectedParsed, env.Parsed)
+}
diff --git a/openstack/orchestration/v1/stacks/errors.go b/openstack/orchestration/v1/stacks/errors.go
new file mode 100644
index 0000000..cd6c18f
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/errors.go
@@ -0,0 +1,33 @@
+package stacks
+
+import (
+	"fmt"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+type ErrInvalidEnvironment struct {
+	gophercloud.BaseError
+	Section string
+}
+
+func (e ErrInvalidEnvironment) Error() string {
+	return fmt.Sprintf("Environment has wrong section: %s", e.Section)
+}
+
+type ErrInvalidDataFormat struct {
+	gophercloud.BaseError
+}
+
+func (e ErrInvalidDataFormat) Error() string {
+	return fmt.Sprintf("Data in neither json nor yaml format.")
+}
+
+type ErrInvalidTemplateFormatVersion struct {
+	gophercloud.BaseError
+	Version string
+}
+
+func (e ErrInvalidTemplateFormatVersion) Error() string {
+	return fmt.Sprintf("Template format version not found.")
+}
diff --git a/openstack/orchestration/v1/stacks/fixtures.go b/openstack/orchestration/v1/stacks/fixtures.go
new file mode 100644
index 0000000..f1d66f4
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/fixtures.go
@@ -0,0 +1,604 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/gophercloud/gophercloud"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// CreateExpected represents the expected object from a Create request.
+var CreateExpected = &CreatedStack{
+	ID: "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	Links: []gophercloud.Link{
+		gophercloud.Link{
+			Href: "http://168.28.170.117:8004/v1/98606384f58drad0bhdb7d02779549ac/stacks/stackcreated/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+			Rel:  "self",
+		},
+	},
+}
+
+// CreateOutput represents the response body from a Create request.
+const CreateOutput = `
+{
+  "stack": {
+    "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+    "links": [
+    {
+      "href": "http://168.28.170.117:8004/v1/98606384f58drad0bhdb7d02779549ac/stacks/stackcreated/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+      "rel": "self"
+    }
+    ]
+  }
+}`
+
+// HandleCreateSuccessfully creates an HTTP handler at `/stacks` on the test handler mux
+// that responds with a `Create` response.
+func HandleCreateSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ListExpected represents the expected object from a List request.
+var ListExpected = []ListedStack{
+	ListedStack{
+		Description: "Simple template to test heat commands",
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+				Rel:  "self",
+			},
+		},
+		StatusReason: "Stack CREATE completed successfully",
+		Name:         "postman_stack",
+		CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC)),
+		Status:       "CREATE_COMPLETE",
+		ID:           "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+		Tags:         []string{"rackspace", "atx"},
+	},
+	ListedStack{
+		Description: "Simple template to test heat commands",
+		Links: []gophercloud.Link{
+			gophercloud.Link{
+				Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada",
+				Rel:  "self",
+			},
+		},
+		StatusReason: "Stack successfully updated",
+		Name:         "gophercloud-test-stack-2",
+		CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2014, 12, 11, 17, 39, 16, 0, time.UTC)),
+		UpdatedTime:  gophercloud.JSONRFC3339NoZ(time.Date(2014, 12, 11, 17, 40, 37, 0, time.UTC)),
+		Status:       "UPDATE_COMPLETE",
+		ID:           "db6977b2-27aa-4775-9ae7-6213212d4ada",
+		Tags:         []string{"sfo", "satx"},
+	},
+}
+
+// FullListOutput represents the response body from a List request without a marker.
+const FullListOutput = `
+{
+  "stacks": [
+  {
+    "description": "Simple template to test heat commands",
+    "links": [
+    {
+      "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+      "rel": "self"
+    }
+    ],
+    "stack_status_reason": "Stack CREATE completed successfully",
+    "stack_name": "postman_stack",
+    "creation_time": "2015-02-03T20:07:39",
+    "updated_time": null,
+    "stack_status": "CREATE_COMPLETE",
+    "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	"tags": ["rackspace", "atx"]
+  },
+  {
+    "description": "Simple template to test heat commands",
+    "links": [
+    {
+      "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada",
+      "rel": "self"
+    }
+    ],
+    "stack_status_reason": "Stack successfully updated",
+    "stack_name": "gophercloud-test-stack-2",
+    "creation_time": "2014-12-11T17:39:16",
+    "updated_time": "2014-12-11T17:40:37",
+    "stack_status": "UPDATE_COMPLETE",
+    "id": "db6977b2-27aa-4775-9ae7-6213212d4ada",
+	"tags": ["sfo", "satx"]
+  }
+  ]
+}
+`
+
+// HandleListSuccessfully creates an HTTP handler at `/stacks` on the test handler mux
+// that responds with a `List` response.
+func HandleListSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, output)
+		case "db6977b2-27aa-4775-9ae7-6213212d4ada":
+			fmt.Fprintf(w, `[]`)
+		default:
+			t.Fatalf("Unexpected marker: [%s]", marker)
+		}
+	})
+}
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = &RetrievedStack{
+	DisableRollback: true,
+	Description:     "Simple template to test heat commands",
+	Parameters: map[string]string{
+		"flavor":         "m1.tiny",
+		"OS::stack_name": "postman_stack",
+		"OS::stack_id":   "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	},
+	StatusReason: "Stack CREATE completed successfully",
+	Name:         "postman_stack",
+	Outputs:      []map[string]interface{}{},
+	CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC)),
+	Links: []gophercloud.Link{
+		gophercloud.Link{
+			Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+			Rel:  "self",
+		},
+	},
+	Capabilities:        []interface{}{},
+	NotificationTopics:  []interface{}{},
+	Status:              "CREATE_COMPLETE",
+	ID:                  "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	TemplateDescription: "Simple template to test heat commands",
+	Tags:                []string{"rackspace", "atx"},
+}
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+  "stack": {
+    "disable_rollback": true,
+    "description": "Simple template to test heat commands",
+    "parameters": {
+      "flavor": "m1.tiny",
+      "OS::stack_name": "postman_stack",
+      "OS::stack_id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87"
+    },
+    "stack_status_reason": "Stack CREATE completed successfully",
+    "stack_name": "postman_stack",
+    "outputs": [],
+    "creation_time": "2015-02-03T20:07:39",
+    "links": [
+    {
+      "href": "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+      "rel": "self"
+    }
+    ],
+    "capabilities": [],
+    "notification_topics": [],
+    "timeout_mins": null,
+    "stack_status": "CREATE_COMPLETE",
+    "updated_time": null,
+    "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+    "template_description": "Simple template to test heat commands",
+	"tags": ["rackspace", "atx"]
+  }
+}
+`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// HandleUpdateSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with an `Update` response.
+func HandleUpdateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusAccepted)
+	})
+}
+
+// HandleDeleteSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87`
+// on the test handler mux that responds with a `Delete` response.
+func HandleDeleteSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/stacks/gophercloud-test-stack-2/db6977b2-27aa-4775-9ae7-6213212d4ada", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNoContent)
+	})
+}
+
+// GetExpected represents the expected object from a Get request.
+var PreviewExpected = &PreviewedStack{
+	DisableRollback: true,
+	Description:     "Simple template to test heat commands",
+	Parameters: map[string]string{
+		"flavor":         "m1.tiny",
+		"OS::stack_name": "postman_stack",
+		"OS::stack_id":   "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	},
+	Name:         "postman_stack",
+	CreationTime: gophercloud.JSONRFC3339NoZ(time.Date(2015, 2, 3, 20, 7, 39, 0, time.UTC)),
+	Links: []gophercloud.Link{
+		gophercloud.Link{
+			Href: "http://166.76.160.117:8004/v1/98606384f58d4ad0b3db7d0d779549ac/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+			Rel:  "self",
+		},
+	},
+	Capabilities:        []interface{}{},
+	NotificationTopics:  []interface{}{},
+	ID:                  "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	TemplateDescription: "Simple template to test heat commands",
+}
+
+// HandlePreviewSuccessfully creates an HTTP handler at `/stacks/preview`
+// on the test handler mux that responds with a `Preview` response.
+func HandlePreviewSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/preview", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// AbandonExpected represents the expected object from an Abandon request.
+var AbandonExpected = &AbandonedStack{
+	Status: "COMPLETE",
+	Name:   "postman_stack",
+	Template: map[string]interface{}{
+		"heat_template_version": "2013-05-23",
+		"description":           "Simple template to test heat commands",
+		"parameters": map[string]interface{}{
+			"flavor": map[string]interface{}{
+				"default": "m1.tiny",
+				"type":    "string",
+			},
+		},
+		"resources": map[string]interface{}{
+			"hello_world": map[string]interface{}{
+				"type": "OS::Nova::Server",
+				"properties": map[string]interface{}{
+					"key_name": "heat_key",
+					"flavor": map[string]interface{}{
+						"get_param": "flavor",
+					},
+					"image":     "ad091b52-742f-469e-8f3c-fd81cadf0743",
+					"user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n",
+				},
+			},
+		},
+	},
+	Action: "CREATE",
+	ID:     "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+	Resources: map[string]interface{}{
+		"hello_world": map[string]interface{}{
+			"status":      "COMPLETE",
+			"name":        "hello_world",
+			"resource_id": "8a310d36-46fc-436f-8be4-37a696b8ac63",
+			"action":      "CREATE",
+			"type":        "OS::Nova::Server",
+		},
+	},
+	Files: map[string]string{
+		"file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "heat_template_version: 2014-10-16\nparameters:\n  flavor:\n    type: string\n    description: Flavor for the server to be created\n    default: 4353\n    hidden: true\nresources:\n  test_server:\n    type: \"OS::Nova::Server\"\n    properties:\n      name: test-server\n      flavor: 2 GB General Purpose v1\n image: Debian 7 (Wheezy) (PVHVM)\n",
+	},
+	StackUserProjectID: "897686",
+	ProjectID:          "897686",
+	Environment: map[string]interface{}{
+		"encrypted_param_names": make([]map[string]interface{}, 0),
+		"parameter_defaults":    make(map[string]interface{}),
+		"parameters":            make(map[string]interface{}),
+		"resource_registry": map[string]interface{}{
+			"file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml",
+			"resources": make(map[string]interface{}),
+		},
+	},
+}
+
+// AbandonOutput represents the response body from an Abandon request.
+const AbandonOutput = `
+{
+  "status": "COMPLETE",
+  "name": "postman_stack",
+  "template": {
+    "heat_template_version": "2013-05-23",
+    "description": "Simple template to test heat commands",
+    "parameters": {
+      "flavor": {
+        "default": "m1.tiny",
+        "type": "string"
+      }
+    },
+    "resources": {
+      "hello_world": {
+        "type": "OS::Nova::Server",
+        "properties": {
+          "key_name": "heat_key",
+          "flavor": {
+            "get_param": "flavor"
+          },
+          "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+          "user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n"
+        }
+      }
+    }
+  },
+  "action": "CREATE",
+  "id": "16ef0584-4458-41eb-87c8-0dc8d5f66c87",
+  "resources": {
+    "hello_world": {
+      "status": "COMPLETE",
+      "name": "hello_world",
+      "resource_id": "8a310d36-46fc-436f-8be4-37a696b8ac63",
+      "action": "CREATE",
+      "type": "OS::Nova::Server"
+    }
+  },
+  "files": {
+    "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "heat_template_version: 2014-10-16\nparameters:\n  flavor:\n    type: string\n    description: Flavor for the server to be created\n    default: 4353\n    hidden: true\nresources:\n  test_server:\n    type: \"OS::Nova::Server\"\n    properties:\n      name: test-server\n      flavor: 2 GB General Purpose v1\n image: Debian 7 (Wheezy) (PVHVM)\n"
+},
+  "environment": {
+	"encrypted_param_names": [],
+	"parameter_defaults": {},
+	"parameters": {},
+	"resource_registry": {
+		"file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml": "file:///Users/prat8228/go/src/github.com/rackspace/rack/my_nova.yaml",
+		"resources": {}
+	}
+  },
+  "stack_user_project_id": "897686",
+  "project_id": "897686"
+}`
+
+// HandleAbandonSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/abandon`
+// on the test handler mux that responds with an `Abandon` response.
+func HandleAbandonSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c8/abandon", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "DELETE")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ValidJSONTemplate is a valid OpenStack Heat template in JSON format
+const ValidJSONTemplate = `
+{
+  "heat_template_version": "2014-10-16",
+  "parameters": {
+    "flavor": {
+      "default": 4353,
+      "description": "Flavor for the server to be created",
+      "hidden": true,
+      "type": "string"
+    }
+  },
+  "resources": {
+    "test_server": {
+      "properties": {
+        "flavor": "2 GB General Purpose v1",
+        "image": "Debian 7 (Wheezy) (PVHVM)",
+        "name": "test-server"
+      },
+      "type": "OS::Nova::Server"
+    }
+  }
+}
+`
+
+// ValidJSONTemplateParsed is the expected parsed version of ValidJSONTemplate
+var ValidJSONTemplateParsed = map[string]interface{}{
+	"heat_template_version": "2014-10-16",
+	"parameters": map[string]interface{}{
+		"flavor": map[string]interface{}{
+			"default":     4353,
+			"description": "Flavor for the server to be created",
+			"hidden":      true,
+			"type":        "string",
+		},
+	},
+	"resources": map[string]interface{}{
+		"test_server": map[string]interface{}{
+			"properties": map[string]interface{}{
+				"flavor": "2 GB General Purpose v1",
+				"image":  "Debian 7 (Wheezy) (PVHVM)",
+				"name":   "test-server",
+			},
+			"type": "OS::Nova::Server",
+		},
+	},
+}
+
+// ValidYAMLTemplate is a valid OpenStack Heat template in YAML format
+const ValidYAMLTemplate = `
+heat_template_version: 2014-10-16
+parameters:
+  flavor:
+    type: string
+    description: Flavor for the server to be created
+    default: 4353
+    hidden: true
+resources:
+  test_server:
+    type: "OS::Nova::Server"
+    properties:
+      name: test-server
+      flavor: 2 GB General Purpose v1
+      image: Debian 7 (Wheezy) (PVHVM)
+`
+
+// InvalidTemplateNoVersion is an invalid template as it has no `version` section
+const InvalidTemplateNoVersion = `
+parameters:
+  flavor:
+    type: string
+    description: Flavor for the server to be created
+    default: 4353
+    hidden: true
+resources:
+  test_server:
+    type: "OS::Nova::Server"
+    properties:
+      name: test-server
+      flavor: 2 GB General Purpose v1
+      image: Debian 7 (Wheezy) (PVHVM)
+`
+
+// ValidJSONEnvironment is a valid environment for a stack in JSON format
+const ValidJSONEnvironment = `
+{
+  "parameters": {
+    "user_key": "userkey"
+  },
+  "resource_registry": {
+    "My::WP::Server": "file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml",
+    "OS::Quantum*": "OS::Neutron*",
+    "AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml",
+    "OS::Metering::Alarm": "OS::Ceilometer::Alarm",
+    "AWS::RDS::DBInstance": "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml",
+    "resources": {
+      "my_db_server": {
+        "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml"
+      },
+      "my_server": {
+        "OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+        "hooks": "pre-create"
+      },
+      "nested_stack": {
+        "nested_resource": {
+          "hooks": "pre-update"
+        },
+        "another_resource": {
+          "hooks": [
+            "pre-create",
+            "pre-update"
+          ]
+        }
+      }
+    }
+  }
+}
+`
+
+// ValidJSONEnvironmentParsed is the expected parsed version of ValidJSONEnvironment
+var ValidJSONEnvironmentParsed = map[string]interface{}{
+	"parameters": map[string]interface{}{
+		"user_key": "userkey",
+	},
+	"resource_registry": map[string]interface{}{
+		"My::WP::Server":         "file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml",
+		"OS::Quantum*":           "OS::Neutron*",
+		"AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml",
+		"OS::Metering::Alarm":    "OS::Ceilometer::Alarm",
+		"AWS::RDS::DBInstance":   "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml",
+		"resources": map[string]interface{}{
+			"my_db_server": map[string]interface{}{
+				"OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+			},
+			"my_server": map[string]interface{}{
+				"OS::DBInstance": "file:///home/mine/all_my_cool_templates/db.yaml",
+				"hooks":          "pre-create",
+			},
+			"nested_stack": map[string]interface{}{
+				"nested_resource": map[string]interface{}{
+					"hooks": "pre-update",
+				},
+				"another_resource": map[string]interface{}{
+					"hooks": []interface{}{
+						"pre-create",
+						"pre-update",
+					},
+				},
+			},
+		},
+	},
+}
+
+// ValidYAMLEnvironment is a valid environment for a stack in YAML format
+const ValidYAMLEnvironment = `
+parameters:
+  user_key: userkey
+resource_registry:
+  My::WP::Server: file:///home/shardy/git/heat-templates/hot/F18/WordPress_Native.yaml
+  # allow older templates with Quantum in them.
+  "OS::Quantum*": "OS::Neutron*"
+  # Choose your implementation of AWS::CloudWatch::Alarm
+  "AWS::CloudWatch::Alarm": "file:///etc/heat/templates/AWS_CloudWatch_Alarm.yaml"
+  #"AWS::CloudWatch::Alarm": "OS::Heat::CWLiteAlarm"
+  "OS::Metering::Alarm": "OS::Ceilometer::Alarm"
+  "AWS::RDS::DBInstance": "file:///etc/heat/templates/AWS_RDS_DBInstance.yaml"
+  resources:
+    my_db_server:
+      "OS::DBInstance": file:///home/mine/all_my_cool_templates/db.yaml
+    my_server:
+      "OS::DBInstance": file:///home/mine/all_my_cool_templates/db.yaml
+      hooks: pre-create
+    nested_stack:
+      nested_resource:
+        hooks: pre-update
+      another_resource:
+        hooks: [pre-create, pre-update]
+`
+
+// InvalidEnvironment is an invalid environment as it has an extra section called `resources`
+const InvalidEnvironment = `
+parameters:
+  flavor:
+    type: string
+    description: Flavor for the server to be created
+    default: 4353
+    hidden: true
+resources:
+  test_server:
+    type: "OS::Nova::Server"
+    properties:
+      name: test-server
+      flavor: 2 GB General Purpose v1
+      image: Debian 7 (Wheezy) (PVHVM)
+parameter_defaults:
+  KeyName: heat_key
+`
diff --git a/openstack/orchestration/v1/stacks/requests.go b/openstack/orchestration/v1/stacks/requests.go
new file mode 100644
index 0000000..91f38ee
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/requests.go
@@ -0,0 +1,440 @@
+package stacks
+
+import (
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the main Create operation in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type CreateOptsBuilder interface {
+	ToStackCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts is the common options struct used in this package's Create
+// operation.
+type CreateOpts struct {
+	// The name of the stack. It must start with an alphabetic character.
+	Name string `json:"stack_name" required:"true"`
+	// A structure that contains either the template file or url. Call the
+	// associated methods to extract the information relevant to send in a create request.
+	TemplateOpts *Template `json:"-" required:"true"`
+	// Enables or disables deletion of all stack resources when a stack
+	// creation fails. Default is true, meaning all resources are not deleted when
+	// stack creation fails.
+	DisableRollback *bool `json:"disable_rollback,omitempty"`
+	// A structure that contains details for the environment of the stack.
+	EnvironmentOpts *Environment `json:"-"`
+	// User-defined parameters to pass to the template.
+	Parameters map[string]string `json:"parameters,omitempty"`
+	// The timeout for stack creation in minutes.
+	Timeout int `json:"timeout_mins,omitempty"`
+	// A list of tags to assosciate with the Stack
+	Tags []string `json:"-"`
+}
+
+// ToStackCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToStackCreateMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.Parse(); err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+		return nil, err
+	}
+	opts.TemplateOpts.fixFileRefs()
+	b["template"] = string(opts.TemplateOpts.Bin)
+
+	files := make(map[string]string)
+	for k, v := range opts.TemplateOpts.Files {
+		files[k] = v
+	}
+
+	if opts.EnvironmentOpts != nil {
+		if err := opts.EnvironmentOpts.Parse(); err != nil {
+			return nil, err
+		}
+		if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+			return nil, err
+		}
+		opts.EnvironmentOpts.fixFileRefs()
+		for k, v := range opts.EnvironmentOpts.Files {
+			files[k] = v
+		}
+		b["environment"] = string(opts.EnvironmentOpts.Bin)
+	}
+
+	if len(files) > 0 {
+		b["files"] = files
+	}
+
+	if opts.Tags != nil {
+		b["tags"] = strings.Join(opts.Tags, ",")
+	}
+
+	return b, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new stack using the values
+// provided.
+func Create(c *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+	b, err := opts.ToStackCreateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(createURL(c), b, &r.Body, nil)
+	return
+}
+
+// AdoptOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Adopt function in this package. Since many
+// extensions decorate or modify the common logic, it is useful for them to
+// satisfy a basic interface in order for them to be used.
+type AdoptOptsBuilder interface {
+	ToStackAdoptMap() (map[string]interface{}, error)
+}
+
+// AdoptOpts is the common options struct used in this package's Adopt
+// operation.
+type AdoptOpts struct {
+	// Existing resources data represented as a string to add to the
+	// new stack. Data returned by Abandon could be provided as AdoptsStackData.
+	AdoptStackData string `json:"adopt_stack_data" required:"true"`
+	// The name of the stack. It must start with an alphabetic character.
+	Name string `json:"stack_name" required:"true"`
+	// A structure that contains either the template file or url. Call the
+	// associated methods to extract the information relevant to send in a create request.
+	TemplateOpts *Template `json:"-" required:"true"`
+	// The timeout for stack creation in minutes.
+	Timeout int `json:"timeout_mins,omitempty"`
+	// A structure that contains either the template file or url. Call the
+	// associated methods to extract the information relevant to send in a create request.
+	//TemplateOpts *Template `json:"-" required:"true"`
+	// Enables or disables deletion of all stack resources when a stack
+	// creation fails. Default is true, meaning all resources are not deleted when
+	// stack creation fails.
+	DisableRollback *bool `json:"disable_rollback,omitempty"`
+	// A structure that contains details for the environment of the stack.
+	EnvironmentOpts *Environment `json:"-"`
+	// User-defined parameters to pass to the template.
+	Parameters map[string]string `json:"parameters,omitempty"`
+}
+
+// ToStackAdoptMap casts a CreateOpts struct to a map.
+func (opts AdoptOpts) ToStackAdoptMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.Parse(); err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+		return nil, err
+	}
+	opts.TemplateOpts.fixFileRefs()
+	b["template"] = string(opts.TemplateOpts.Bin)
+
+	files := make(map[string]string)
+	for k, v := range opts.TemplateOpts.Files {
+		files[k] = v
+	}
+
+	if opts.EnvironmentOpts != nil {
+		if err := opts.EnvironmentOpts.Parse(); err != nil {
+			return nil, err
+		}
+		if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+			return nil, err
+		}
+		opts.EnvironmentOpts.fixFileRefs()
+		for k, v := range opts.EnvironmentOpts.Files {
+			files[k] = v
+		}
+		b["environment"] = string(opts.EnvironmentOpts.Bin)
+	}
+
+	if len(files) > 0 {
+		b["files"] = files
+	}
+
+	return b, nil
+}
+
+// Adopt accepts an AdoptOpts struct and creates a new stack using the resources
+// from another stack.
+func Adopt(c *gophercloud.ServiceClient, opts AdoptOptsBuilder) (r AdoptResult) {
+	b, err := opts.ToStackAdoptMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(adoptURL(c), b, &r.Body, nil)
+	return
+}
+
+// SortDir is a type for specifying in which direction to sort a list of stacks.
+type SortDir string
+
+// SortKey is a type for specifying by which key to sort a list of stacks.
+type SortKey string
+
+var (
+	// SortAsc is used to sort a list of stacks in ascending order.
+	SortAsc SortDir = "asc"
+	// SortDesc is used to sort a list of stacks in descending order.
+	SortDesc SortDir = "desc"
+	// SortName is used to sort a list of stacks by name.
+	SortName SortKey = "name"
+	// SortStatus is used to sort a list of stacks by status.
+	SortStatus SortKey = "status"
+	// SortCreatedAt is used to sort a list of stacks by date created.
+	SortCreatedAt SortKey = "created_at"
+	// SortUpdatedAt is used to sort a list of stacks by date updated.
+	SortUpdatedAt SortKey = "updated_at"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+	ToStackListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the network attributes you want to see returned. SortKey allows you to sort
+// by a particular network attribute. SortDir sets the direction, and is either
+// `asc' or `desc'. Marker and Limit are used for pagination.
+type ListOpts struct {
+	Status  string  `q:"status"`
+	Name    string  `q:"name"`
+	Marker  string  `q:"marker"`
+	Limit   int     `q:"limit"`
+	SortKey SortKey `q:"sort_keys"`
+	SortDir SortDir `q:"sort_dir"`
+}
+
+// ToStackListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToStackListQuery() (string, error) {
+	q, err := gophercloud.BuildQueryString(opts)
+	if err != nil {
+		return "", err
+	}
+	return q.String(), nil
+}
+
+// List returns a Pager which allows you to iterate over a collection of
+// stacks. It accepts a ListOpts struct, which allows you to filter and sort
+// the returned collection for greater efficiency.
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+	url := listURL(c)
+	if opts != nil {
+		query, err := opts.ToStackListQuery()
+		if err != nil {
+			return pagination.Pager{Err: err}
+		}
+		url += query
+	}
+	createPage := func(r pagination.PageResult) pagination.Page {
+		return StackPage{pagination.SinglePageBase(r)}
+	}
+	return pagination.NewPager(c, url, createPage)
+}
+
+// Get retreives a stack based on the stack name and stack ID.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, stackName, stackID), &r.Body, nil)
+	return
+}
+
+// UpdateOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Update operation in this package.
+type UpdateOptsBuilder interface {
+	ToStackUpdateMap() (map[string]interface{}, error)
+}
+
+// UpdateOpts contains the common options struct used in this package's Update
+// operation.
+type UpdateOpts struct {
+	// A structure that contains either the template file or url. Call the
+	// associated methods to extract the information relevant to send in a create request.
+	TemplateOpts *Template `json:"-" required:"true"`
+	// A structure that contains details for the environment of the stack.
+	EnvironmentOpts *Environment `json:"-"`
+	// User-defined parameters to pass to the template.
+	Parameters map[string]string `json:"parameters,omitempty"`
+	// The timeout for stack creation in minutes.
+	Timeout int `json:"timeout_mins,omitempty"`
+	// A list of tags to assosciate with the Stack
+	Tags []string `json:"-"`
+}
+
+// ToStackUpdateMap casts a CreateOpts struct to a map.
+func (opts UpdateOpts) ToStackUpdateMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.Parse(); err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+		return nil, err
+	}
+	opts.TemplateOpts.fixFileRefs()
+	b["template"] = string(opts.TemplateOpts.Bin)
+
+	files := make(map[string]string)
+	for k, v := range opts.TemplateOpts.Files {
+		files[k] = v
+	}
+
+	if opts.EnvironmentOpts != nil {
+		if err := opts.EnvironmentOpts.Parse(); err != nil {
+			return nil, err
+		}
+		if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+			return nil, err
+		}
+		opts.EnvironmentOpts.fixFileRefs()
+		for k, v := range opts.EnvironmentOpts.Files {
+			files[k] = v
+		}
+		b["environment"] = string(opts.EnvironmentOpts.Bin)
+	}
+
+	if len(files) > 0 {
+		b["files"] = files
+	}
+
+	if opts.Tags != nil {
+		b["tags"] = strings.Join(opts.Tags, ",")
+	}
+
+	return b, nil
+}
+
+// Update accepts an UpdateOpts struct and updates an existing stack using the values
+// provided.
+func Update(c *gophercloud.ServiceClient, stackName, stackID string, opts UpdateOptsBuilder) (r UpdateResult) {
+	b, err := opts.ToStackUpdateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Put(updateURL(c, stackName, stackID), b, nil, nil)
+	return
+}
+
+// Delete deletes a stack based on the stack name and stack ID.
+func Delete(c *gophercloud.ServiceClient, stackName, stackID string) (r DeleteResult) {
+	_, r.Err = c.Delete(deleteURL(c, stackName, stackID), nil)
+	return
+}
+
+// PreviewOptsBuilder is the interface options structs have to satisfy in order
+// to be used in the Preview operation in this package.
+type PreviewOptsBuilder interface {
+	ToStackPreviewMap() (map[string]interface{}, error)
+}
+
+// PreviewOpts contains the common options struct used in this package's Preview
+// operation.
+type PreviewOpts struct {
+	// The name of the stack. It must start with an alphabetic character.
+	Name string `json:"stack_name" required:"true"`
+	// The timeout for stack creation in minutes.
+	Timeout int `json:"timeout_mins" required:"true"`
+	// A structure that contains either the template file or url. Call the
+	// associated methods to extract the information relevant to send in a create request.
+	TemplateOpts *Template `json:"-" required:"true"`
+	// Enables or disables deletion of all stack resources when a stack
+	// creation fails. Default is true, meaning all resources are not deleted when
+	// stack creation fails.
+	DisableRollback *bool `json:"disable_rollback,omitempty"`
+	// A structure that contains details for the environment of the stack.
+	EnvironmentOpts *Environment `json:"-"`
+	// User-defined parameters to pass to the template.
+	Parameters map[string]string `json:"parameters,omitempty"`
+}
+
+// ToStackPreviewMap casts a PreviewOpts struct to a map.
+func (opts PreviewOpts) ToStackPreviewMap() (map[string]interface{}, error) {
+	b, err := gophercloud.BuildRequestBody(opts, "")
+	if err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.Parse(); err != nil {
+		return nil, err
+	}
+
+	if err := opts.TemplateOpts.getFileContents(opts.TemplateOpts.Parsed, ignoreIfTemplate, true); err != nil {
+		return nil, err
+	}
+	opts.TemplateOpts.fixFileRefs()
+	b["template"] = string(opts.TemplateOpts.Bin)
+
+	files := make(map[string]string)
+	for k, v := range opts.TemplateOpts.Files {
+		files[k] = v
+	}
+
+	if opts.EnvironmentOpts != nil {
+		if err := opts.EnvironmentOpts.Parse(); err != nil {
+			return nil, err
+		}
+		if err := opts.EnvironmentOpts.getRRFileContents(ignoreIfEnvironment); err != nil {
+			return nil, err
+		}
+		opts.EnvironmentOpts.fixFileRefs()
+		for k, v := range opts.EnvironmentOpts.Files {
+			files[k] = v
+		}
+		b["environment"] = string(opts.EnvironmentOpts.Bin)
+	}
+
+	if len(files) > 0 {
+		b["files"] = files
+	}
+
+	return b, nil
+}
+
+// Preview accepts a PreviewOptsBuilder interface and creates a preview of a stack using the values
+// provided.
+func Preview(c *gophercloud.ServiceClient, opts PreviewOptsBuilder) (r PreviewResult) {
+	b, err := opts.ToStackPreviewMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(previewURL(c), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
+
+// Abandon deletes the stack with the provided stackName and stackID, but leaves its
+// resources intact, and returns data describing the stack and its resources.
+func Abandon(c *gophercloud.ServiceClient, stackName, stackID string) (r AbandonResult) {
+	_, r.Err = c.Delete(abandonURL(c, stackName, stackID), &gophercloud.RequestOpts{
+		JSONResponse: &r.Body,
+		OkCodes:      []int{200},
+	})
+	return
+}
diff --git a/openstack/orchestration/v1/stacks/requests_test.go b/openstack/orchestration/v1/stacks/requests_test.go
new file mode 100644
index 0000000..5cff622
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/requests_test.go
@@ -0,0 +1,191 @@
+package stacks
+
+import (
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestCreateStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateSuccessfully(t, CreateOutput)
+	template := new(Template)
+	template.Bin = []byte(`
+		{
+			"heat_template_version": "2013-05-23",
+			"description": "Simple template to test heat commands",
+			"parameters": {
+				"flavor": {
+					"default": "m1.tiny",
+					"type": "string"
+				}
+			}
+		}`)
+	createOpts := CreateOpts{
+		Name:            "stackcreated",
+		Timeout:         60,
+		TemplateOpts:    template,
+		DisableRollback: gophercloud.Disabled,
+	}
+	actual, err := Create(fake.ServiceClient(), createOpts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := CreateExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestAdoptStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleCreateSuccessfully(t, CreateOutput)
+	template := new(Template)
+	template.Bin = []byte(`
+{
+  "stack_name": "postman_stack",
+  "template": {
+	"heat_template_version": "2013-05-23",
+	"description": "Simple template to test heat commands",
+	"parameters": {
+	  "flavor": {
+		"default": "m1.tiny",
+		"type": "string"
+	  }
+	},
+	"resources": {
+	  "hello_world": {
+		"type":"OS::Nova::Server",
+		"properties": {
+		  "key_name": "heat_key",
+		  "flavor": {
+			"get_param": "flavor"
+		  },
+		  "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+		  "user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n"
+		}
+	  }
+	}
+  }
+}`)
+	adoptOpts := AdoptOpts{
+		AdoptStackData:  `{environment{parameters{}}}`,
+		Name:            "stackcreated",
+		Timeout:         60,
+		TemplateOpts:    template,
+		DisableRollback: gophercloud.Disabled,
+	}
+	actual, err := Adopt(fake.ServiceClient(), adoptOpts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := CreateExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestListStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleListSuccessfully(t, FullListOutput)
+
+	count := 0
+	err := List(fake.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractStacks(page)
+		th.AssertNoErr(t, err)
+
+		th.CheckDeepEquals(t, ListExpected, actual)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, count, 1)
+}
+
+func TestGetStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t, GetOutput)
+
+	actual, err := Get(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestUpdateStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleUpdateSuccessfully(t)
+
+	template := new(Template)
+	template.Bin = []byte(`
+		{
+			"heat_template_version": "2013-05-23",
+			"description": "Simple template to test heat commands",
+			"parameters": {
+				"flavor": {
+					"default": "m1.tiny",
+					"type": "string"
+				}
+			}
+		}`)
+	updateOpts := UpdateOpts{
+		TemplateOpts: template,
+	}
+	err := Update(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada", updateOpts).ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestDeleteStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleDeleteSuccessfully(t)
+
+	err := Delete(fake.ServiceClient(), "gophercloud-test-stack-2", "db6977b2-27aa-4775-9ae7-6213212d4ada").ExtractErr()
+	th.AssertNoErr(t, err)
+}
+
+func TestPreviewStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandlePreviewSuccessfully(t, GetOutput)
+
+	template := new(Template)
+	template.Bin = []byte(`
+		{
+			"heat_template_version": "2013-05-23",
+			"description": "Simple template to test heat commands",
+			"parameters": {
+				"flavor": {
+					"default": "m1.tiny",
+					"type": "string"
+				}
+			}
+		}`)
+	previewOpts := PreviewOpts{
+		Name:            "stackcreated",
+		Timeout:         60,
+		TemplateOpts:    template,
+		DisableRollback: gophercloud.Disabled,
+	}
+	actual, err := Preview(fake.ServiceClient(), previewOpts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := PreviewExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestAbandonStack(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleAbandonSuccessfully(t, AbandonOutput)
+
+	actual, err := Abandon(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c8").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := AbandonExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stacks/results.go b/openstack/orchestration/v1/stacks/results.go
new file mode 100644
index 0000000..6b6f3a3
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/results.go
@@ -0,0 +1,180 @@
+package stacks
+
+import (
+	"encoding/json"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/pagination"
+)
+
+// CreatedStack represents the object extracted from a Create operation.
+type CreatedStack struct {
+	ID    string             `json:"id"`
+	Links []gophercloud.Link `json:"links"`
+}
+
+// CreateResult represents the result of a Create operation.
+type CreateResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a CreatedStack object and is called after a
+// Create operation.
+func (r CreateResult) Extract() (*CreatedStack, error) {
+	var s struct {
+		CreatedStack *CreatedStack `json:"stack"`
+	}
+	err := r.ExtractInto(&s)
+	return s.CreatedStack, err
+}
+
+// AdoptResult represents the result of an Adopt operation. AdoptResult has the
+// same form as CreateResult.
+type AdoptResult struct {
+	CreateResult
+}
+
+// StackPage is a pagination.Pager that is returned from a call to the List function.
+type StackPage struct {
+	pagination.SinglePageBase
+}
+
+// IsEmpty returns true if a ListResult contains no Stacks.
+func (r StackPage) IsEmpty() (bool, error) {
+	stacks, err := ExtractStacks(r)
+	return len(stacks) == 0, err
+}
+
+// ListedStack represents an element in the slice extracted from a List operation.
+type ListedStack struct {
+	CreationTime gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+	Description  string                     `json:"description"`
+	ID           string                     `json:"id"`
+	Links        []gophercloud.Link         `json:"links"`
+	Name         string                     `json:"stack_name"`
+	Status       string                     `json:"stack_status"`
+	StatusReason string                     `json:"stack_status_reason"`
+	Tags         []string                   `json:"tags"`
+	UpdatedTime  gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+}
+
+// ExtractStacks extracts and returns a slice of ListedStack. It is used while iterating
+// over a stacks.List call.
+func ExtractStacks(r pagination.Page) ([]ListedStack, error) {
+	var s struct {
+		ListedStacks []ListedStack `json:"stacks"`
+	}
+	err := (r.(StackPage)).ExtractInto(&s)
+	return s.ListedStacks, err
+}
+
+// RetrievedStack represents the object extracted from a Get operation.
+type RetrievedStack struct {
+	Capabilities        []interface{}              `json:"capabilities"`
+	CreationTime        gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+	Description         string                     `json:"description"`
+	DisableRollback     bool                       `json:"disable_rollback"`
+	ID                  string                     `json:"id"`
+	Links               []gophercloud.Link         `json:"links"`
+	NotificationTopics  []interface{}              `json:"notification_topics"`
+	Outputs             []map[string]interface{}   `json:"outputs"`
+	Parameters          map[string]string          `json:"parameters"`
+	Name                string                     `json:"stack_name"`
+	Status              string                     `json:"stack_status"`
+	StatusReason        string                     `json:"stack_status_reason"`
+	Tags                []string                   `json:"tags"`
+	TemplateDescription string                     `json:"template_description"`
+	Timeout             int                        `json:"timeout_mins"`
+	UpdatedTime         gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+}
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a RetrievedStack object and is called after a
+// Get operation.
+func (r GetResult) Extract() (*RetrievedStack, error) {
+	var s struct {
+		Stack *RetrievedStack `json:"stack"`
+	}
+	err := r.ExtractInto(&s)
+	return s.Stack, err
+}
+
+// UpdateResult represents the result of a Update operation.
+type UpdateResult struct {
+	gophercloud.ErrResult
+}
+
+// DeleteResult represents the result of a Delete operation.
+type DeleteResult struct {
+	gophercloud.ErrResult
+}
+
+// PreviewedStack represents the result of a Preview operation.
+type PreviewedStack struct {
+	Capabilities        []interface{}              `json:"capabilities"`
+	CreationTime        gophercloud.JSONRFC3339NoZ `json:"creation_time"`
+	Description         string                     `json:"description"`
+	DisableRollback     bool                       `json:"disable_rollback"`
+	ID                  string                     `json:"id"`
+	Links               []gophercloud.Link         `json:"links"`
+	Name                string                     `json:"stack_name"`
+	NotificationTopics  []interface{}              `json:"notification_topics"`
+	Parameters          map[string]string          `json:"parameters"`
+	Resources           []interface{}              `json:"resources"`
+	TemplateDescription string                     `json:"template_description"`
+	Timeout             int                        `json:"timeout_mins"`
+	UpdatedTime         gophercloud.JSONRFC3339NoZ `json:"updated_time"`
+}
+
+// PreviewResult represents the result of a Preview operation.
+type PreviewResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a PreviewedStack object and is called after a
+// Preview operation.
+func (r PreviewResult) Extract() (*PreviewedStack, error) {
+	var s struct {
+		PreviewedStack *PreviewedStack `json:"stack"`
+	}
+	err := r.ExtractInto(&s)
+	return s.PreviewedStack, err
+}
+
+// AbandonedStack represents the result of an Abandon operation.
+type AbandonedStack struct {
+	Status             string                 `json:"status"`
+	Name               string                 `json:"name"`
+	Template           map[string]interface{} `json:"template"`
+	Action             string                 `json:"action"`
+	ID                 string                 `json:"id"`
+	Resources          map[string]interface{} `json:"resources"`
+	Files              map[string]string      `json:"files"`
+	StackUserProjectID string                 `json:"stack_user_project_id"`
+	ProjectID          string                 `json:"project_id"`
+	Environment        map[string]interface{} `json:"environment"`
+}
+
+// AbandonResult represents the result of an Abandon operation.
+type AbandonResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to an AbandonedStack object and is called after an
+// Abandon operation.
+func (r AbandonResult) Extract() (*AbandonedStack, error) {
+	var s *AbandonedStack
+	err := r.ExtractInto(&s)
+	return s, err
+}
+
+// String converts an AbandonResult to a string. This is useful to when passing
+// the result of an Abandon operation to an AdoptOpts AdoptStackData field.
+func (r AbandonResult) String() (string, error) {
+	out, err := json.Marshal(r)
+	return string(out), err
+}
diff --git a/openstack/orchestration/v1/stacks/template.go b/openstack/orchestration/v1/stacks/template.go
new file mode 100644
index 0000000..4cf5aae
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/template.go
@@ -0,0 +1,141 @@
+package stacks
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// Template is a structure that represents OpenStack Heat templates
+type Template struct {
+	TE
+}
+
+// TemplateFormatVersions is a map containing allowed variations of the template format version
+// Note that this contains the permitted variations of the _keys_ not the values.
+var TemplateFormatVersions = map[string]bool{
+	"HeatTemplateFormatVersion": true,
+	"heat_template_version":     true,
+	"AWSTemplateFormatVersion":  true,
+}
+
+// Validate validates the contents of the Template
+func (t *Template) Validate() error {
+	if t.Parsed == nil {
+		if err := t.Parse(); err != nil {
+			return err
+		}
+	}
+	var invalid string
+	for key := range t.Parsed {
+		if _, ok := TemplateFormatVersions[key]; ok {
+			return nil
+		}
+		invalid = key
+	}
+	return ErrInvalidTemplateFormatVersion{Version: invalid}
+}
+
+// GetFileContents recursively parses a template to search for urls. These urls
+// are assumed to point to other templates (known in OpenStack Heat as child
+// templates). The contents of these urls are fetched and stored in the `Files`
+// parameter of the template structure. This is the only way that a user can
+// use child templates that are located in their filesystem; urls located on the
+// web (e.g. on github or swift) can be fetched directly by Heat engine.
+func (t *Template) getFileContents(te interface{}, ignoreIf igFunc, recurse bool) error {
+	// initialize template if empty
+	if t.Files == nil {
+		t.Files = make(map[string]string)
+	}
+	if t.fileMaps == nil {
+		t.fileMaps = make(map[string]string)
+	}
+	switch te.(type) {
+	// if te is a map
+	case map[string]interface{}, map[interface{}]interface{}:
+		teMap, err := toStringKeys(te)
+		if err != nil {
+			return err
+		}
+		for k, v := range teMap {
+			value, ok := v.(string)
+			if !ok {
+				// if the value is not a string, recursively parse that value
+				if err := t.getFileContents(v, ignoreIf, recurse); err != nil {
+					return err
+				}
+			} else if !ignoreIf(k, value) {
+				// at this point, the k, v pair has a reference to an external template.
+				// The assumption of heatclient is that value v is a reference
+				// to a file in the users environment
+
+				// create a new child template
+				childTemplate := new(Template)
+
+				// initialize child template
+
+				// get the base location of the child template
+				baseURL, err := gophercloud.NormalizePathURL(t.baseURL, value)
+				if err != nil {
+					return err
+				}
+				childTemplate.baseURL = baseURL
+				childTemplate.client = t.client
+
+				// fetch the contents of the child template
+				if err := childTemplate.Parse(); err != nil {
+					return err
+				}
+
+				// process child template recursively if required. This is
+				// required if the child template itself contains references to
+				// other templates
+				if recurse {
+					if err := childTemplate.getFileContents(childTemplate.Parsed, ignoreIf, recurse); err != nil {
+						return err
+					}
+				}
+				// update parent template with current child templates' content.
+				// At this point, the child template has been parsed recursively.
+				t.fileMaps[value] = childTemplate.URL
+				t.Files[childTemplate.URL] = string(childTemplate.Bin)
+
+			}
+		}
+		return nil
+	// if te is a slice, call the function on each element of the slice.
+	case []interface{}:
+		teSlice := te.([]interface{})
+		for i := range teSlice {
+			if err := t.getFileContents(teSlice[i], ignoreIf, recurse); err != nil {
+				return err
+			}
+		}
+	// if te is anything else, return
+	case string, bool, float64, nil, int:
+		return nil
+	default:
+		return gophercloud.ErrUnexpectedType{Actual: fmt.Sprintf("%v", reflect.TypeOf(te))}
+	}
+	return nil
+}
+
+// function to choose keys whose values are other template files
+func ignoreIfTemplate(key string, value interface{}) bool {
+	// key must be either `get_file` or `type` for value to be a URL
+	if key != "get_file" && key != "type" {
+		return true
+	}
+	// value must be a string
+	valueString, ok := value.(string)
+	if !ok {
+		return true
+	}
+	// `.template` and `.yaml` are allowed suffixes for template URLs when referred to by `type`
+	if key == "type" && !(strings.HasSuffix(valueString, ".template") || strings.HasSuffix(valueString, ".yaml")) {
+		return true
+	}
+	return false
+}
diff --git a/openstack/orchestration/v1/stacks/template_test.go b/openstack/orchestration/v1/stacks/template_test.go
new file mode 100644
index 0000000..cbe99ed
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/template_test.go
@@ -0,0 +1,148 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestTemplateValidation(t *testing.T) {
+	templateJSON := new(Template)
+	templateJSON.Bin = []byte(ValidJSONTemplate)
+	err := templateJSON.Validate()
+	th.AssertNoErr(t, err)
+
+	templateYAML := new(Template)
+	templateYAML.Bin = []byte(ValidYAMLTemplate)
+	err = templateYAML.Validate()
+	th.AssertNoErr(t, err)
+
+	templateInvalid := new(Template)
+	templateInvalid.Bin = []byte(InvalidTemplateNoVersion)
+	if err = templateInvalid.Validate(); err == nil {
+		t.Error("Template validation did not catch invalid template")
+	}
+}
+
+func TestTemplateParsing(t *testing.T) {
+	templateJSON := new(Template)
+	templateJSON.Bin = []byte(ValidJSONTemplate)
+	err := templateJSON.Parse()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, ValidJSONTemplateParsed, templateJSON.Parsed)
+
+	templateYAML := new(Template)
+	templateYAML.Bin = []byte(ValidJSONTemplate)
+	err = templateYAML.Parse()
+	th.AssertNoErr(t, err)
+	th.AssertDeepEquals(t, ValidJSONTemplateParsed, templateYAML.Parsed)
+
+	templateInvalid := new(Template)
+	templateInvalid.Bin = []byte("Keep Austin Weird")
+	err = templateInvalid.Parse()
+	if err == nil {
+		t.Error("Template parsing did not catch invalid template")
+	}
+}
+
+func TestIgnoreIfTemplate(t *testing.T) {
+	var keyValueTests = []struct {
+		key   string
+		value interface{}
+		out   bool
+	}{
+		{"not_get_file", "afksdf", true},
+		{"not_type", "sdfd", true},
+		{"get_file", "shdfuisd", false},
+		{"type", "dfsdfsd", true},
+		{"type", "sdfubsduf.yaml", false},
+		{"type", "sdfsdufs.template", false},
+		{"type", "sdfsdf.file", true},
+		{"type", map[string]string{"key": "value"}, true},
+	}
+	var result bool
+	for _, kv := range keyValueTests {
+		result = ignoreIfTemplate(kv.key, kv.value)
+		if result != kv.out {
+			t.Errorf("key: %v, value: %v expected: %v, actual: %v", kv.key, kv.value, result, kv.out)
+		}
+	}
+}
+
+func TestGetFileContents(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	baseurl, err := getBasePath()
+	th.AssertNoErr(t, err)
+	fakeURL := strings.Join([]string{baseurl, "my_nova.yaml"}, "/")
+	urlparsed, err := url.Parse(fakeURL)
+	th.AssertNoErr(t, err)
+	myNovaContent := `heat_template_version: 2014-10-16
+parameters:
+  flavor:
+    type: string
+    description: Flavor for the server to be created
+    default: 4353
+    hidden: true
+resources:
+  test_server:
+    type: "OS::Nova::Server"
+    properties:
+      name: test-server
+      flavor: 2 GB General Purpose v1
+      image: Debian 7 (Wheezy) (PVHVM)
+      networks:
+      - {uuid: 11111111-1111-1111-1111-111111111111}`
+	th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		w.Header().Set("Content-Type", "application/jason")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, myNovaContent)
+	})
+
+	client := fakeClient{BaseClient: getHTTPClient()}
+	te := new(Template)
+	te.Bin = []byte(`heat_template_version: 2015-04-30
+resources:
+  my_server:
+    type: my_nova.yaml`)
+	te.client = client
+
+	err = te.Parse()
+	th.AssertNoErr(t, err)
+	err = te.getFileContents(te.Parsed, ignoreIfTemplate, true)
+	th.AssertNoErr(t, err)
+	expectedFiles := map[string]string{
+		"my_nova.yaml": `heat_template_version: 2014-10-16
+parameters:
+  flavor:
+    type: string
+    description: Flavor for the server to be created
+    default: 4353
+    hidden: true
+resources:
+  test_server:
+    type: "OS::Nova::Server"
+    properties:
+      name: test-server
+      flavor: 2 GB General Purpose v1
+      image: Debian 7 (Wheezy) (PVHVM)
+      networks:
+      - {uuid: 11111111-1111-1111-1111-111111111111}`}
+	th.AssertEquals(t, expectedFiles["my_nova.yaml"], te.Files[fakeURL])
+	te.fixFileRefs()
+	expectedParsed := map[string]interface{}{
+		"heat_template_version": "2015-04-30",
+		"resources": map[string]interface{}{
+			"my_server": map[string]interface{}{
+				"type": fakeURL,
+			},
+		},
+	}
+	te.Parse()
+	th.AssertDeepEquals(t, expectedParsed, te.Parsed)
+}
diff --git a/openstack/orchestration/v1/stacks/urls.go b/openstack/orchestration/v1/stacks/urls.go
new file mode 100644
index 0000000..b00be54
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/urls.go
@@ -0,0 +1,35 @@
+package stacks
+
+import "github.com/gophercloud/gophercloud"
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("stacks")
+}
+
+func adoptURL(c *gophercloud.ServiceClient) string {
+	return createURL(c)
+}
+
+func listURL(c *gophercloud.ServiceClient) string {
+	return createURL(c)
+}
+
+func getURL(c *gophercloud.ServiceClient, name, id string) string {
+	return c.ServiceURL("stacks", name, id)
+}
+
+func updateURL(c *gophercloud.ServiceClient, name, id string) string {
+	return getURL(c, name, id)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, name, id string) string {
+	return getURL(c, name, id)
+}
+
+func previewURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("stacks", "preview")
+}
+
+func abandonURL(c *gophercloud.ServiceClient, name, id string) string {
+	return c.ServiceURL("stacks", name, id, "abandon")
+}
diff --git a/openstack/orchestration/v1/stacks/utils.go b/openstack/orchestration/v1/stacks/utils.go
new file mode 100644
index 0000000..71d9e35
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/utils.go
@@ -0,0 +1,160 @@
+package stacks
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"path/filepath"
+	"reflect"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+	"gopkg.in/yaml.v2"
+)
+
+// Client is an interface that expects a Get method similar to http.Get. This
+// is needed for unit testing, since we can mock an http client. Thus, the
+// client will usually be an http.Client EXCEPT in unit tests.
+type Client interface {
+	Get(string) (*http.Response, error)
+}
+
+// TE is a base structure for both Template and Environment
+type TE struct {
+	// Bin stores the contents of the template or environment.
+	Bin []byte
+	// URL stores the URL of the template. This is allowed to be a 'file://'
+	// for local files.
+	URL string
+	// Parsed contains a parsed version of Bin. Since there are 2 different
+	// fields referring to the same value, you must be careful when accessing
+	// this filed.
+	Parsed map[string]interface{}
+	// Files contains a mapping between the urls in templates to their contents.
+	Files map[string]string
+	// fileMaps is a map used internally when determining Files.
+	fileMaps map[string]string
+	// baseURL represents the location of the template or environment file.
+	baseURL string
+	// client is an interface which allows TE to fetch contents from URLS
+	client Client
+}
+
+// Fetch fetches the contents of a TE from its URL. Once a TE structure has a
+// URL, call the fetch method to fetch the contents.
+func (t *TE) Fetch() error {
+	// if the baseURL is not provided, use the current directors as the base URL
+	if t.baseURL == "" {
+		u, err := getBasePath()
+		if err != nil {
+			return err
+		}
+		t.baseURL = u
+	}
+
+	// if the contents are already present, do nothing.
+	if t.Bin != nil {
+		return nil
+	}
+
+	// get a fqdn from the URL using the baseURL of the TE. For local files,
+	// the URL's will have the `file` scheme.
+	u, err := gophercloud.NormalizePathURL(t.baseURL, t.URL)
+	if err != nil {
+		return err
+	}
+	t.URL = u
+
+	// get an HTTP client if none present
+	if t.client == nil {
+		t.client = getHTTPClient()
+	}
+
+	// use the client to fetch the contents of the TE
+	resp, err := t.client.Get(t.URL)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+	t.Bin = body
+	return nil
+}
+
+// get the basepath of the TE
+func getBasePath() (string, error) {
+	basePath, err := filepath.Abs(".")
+	if err != nil {
+		return "", err
+	}
+	u, err := gophercloud.NormalizePathURL("", basePath)
+	if err != nil {
+		return "", err
+	}
+	return u, nil
+}
+
+// get a an HTTP client to retrieve URL's. This client allows the use of `file`
+// scheme since we may need to fetch files from users filesystem
+func getHTTPClient() Client {
+	transport := &http.Transport{}
+	transport.RegisterProtocol("file", http.NewFileTransport(http.Dir("/")))
+	return &http.Client{Transport: transport}
+}
+
+// Parse will parse the contents and then validate. The contents MUST be either JSON or YAML.
+func (t *TE) Parse() error {
+	if err := t.Fetch(); err != nil {
+		return err
+	}
+	if jerr := json.Unmarshal(t.Bin, &t.Parsed); jerr != nil {
+		if yerr := yaml.Unmarshal(t.Bin, &t.Parsed); yerr != nil {
+			return ErrInvalidDataFormat{}
+		}
+	}
+	return t.Validate()
+}
+
+// Validate validates the contents of TE
+func (t *TE) Validate() error {
+	return nil
+}
+
+// igfunc is a parameter used by GetFileContents and GetRRFileContents to check
+// for valid URL's.
+type igFunc func(string, interface{}) bool
+
+// convert map[interface{}]interface{} to map[string]interface{}
+func toStringKeys(m interface{}) (map[string]interface{}, error) {
+	switch m.(type) {
+	case map[string]interface{}, map[interface{}]interface{}:
+		typedMap := make(map[string]interface{})
+		if _, ok := m.(map[interface{}]interface{}); ok {
+			for k, v := range m.(map[interface{}]interface{}) {
+				typedMap[k.(string)] = v
+			}
+		} else {
+			typedMap = m.(map[string]interface{})
+		}
+		return typedMap, nil
+	default:
+		return nil, gophercloud.ErrUnexpectedType{Expected: "map[string]interface{}/map[interface{}]interface{}", Actual: fmt.Sprintf("%v", reflect.TypeOf(m))}
+	}
+}
+
+// fix the reference to files by replacing relative URL's by absolute
+// URL's
+func (t *TE) fixFileRefs() {
+	tStr := string(t.Bin)
+	if t.fileMaps == nil {
+		return
+	}
+	for k, v := range t.fileMaps {
+		tStr = strings.Replace(tStr, k, v, -1)
+	}
+	t.Bin = []byte(tStr)
+}
diff --git a/openstack/orchestration/v1/stacks/utils_test.go b/openstack/orchestration/v1/stacks/utils_test.go
new file mode 100644
index 0000000..4d5cc73
--- /dev/null
+++ b/openstack/orchestration/v1/stacks/utils_test.go
@@ -0,0 +1,94 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestTEFixFileRefs(t *testing.T) {
+	te := TE{
+		Bin: []byte(`string_to_replace: my fair lady`),
+		fileMaps: map[string]string{
+			"string_to_replace": "london bridge is falling down",
+		},
+	}
+	te.fixFileRefs()
+	th.AssertEquals(t, string(te.Bin), `london bridge is falling down: my fair lady`)
+}
+
+func TesttoStringKeys(t *testing.T) {
+	var test1 interface{} = map[interface{}]interface{}{
+		"Adam":  "Smith",
+		"Isaac": "Newton",
+	}
+	result1, err := toStringKeys(test1)
+	th.AssertNoErr(t, err)
+
+	expected := map[string]interface{}{
+		"Adam":  "Smith",
+		"Isaac": "Newton",
+	}
+	th.AssertDeepEquals(t, result1, expected)
+}
+
+func TestGetBasePath(t *testing.T) {
+	_, err := getBasePath()
+	th.AssertNoErr(t, err)
+}
+
+// test if HTTP client can read file type URLS. Read the URL of this file
+// because if this test is running, it means this file _must_ exist
+func TestGetHTTPClient(t *testing.T) {
+	client := getHTTPClient()
+	baseurl, err := getBasePath()
+	th.AssertNoErr(t, err)
+	resp, err := client.Get(baseurl)
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, resp.StatusCode, 200)
+}
+
+// Implement a fakeclient that can be used to mock out HTTP requests
+type fakeClient struct {
+	BaseClient Client
+}
+
+// this client's Get method first changes the URL given to point to
+// testhelper's (th) endpoints. This is done because the http Mux does not seem
+// to work for fqdns with the `file` scheme
+func (c fakeClient) Get(url string) (*http.Response, error) {
+	newurl := strings.Replace(url, "file://", th.Endpoint(), 1)
+	return c.BaseClient.Get(newurl)
+}
+
+// test the fetch function
+func TestFetch(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	baseurl, err := getBasePath()
+	th.AssertNoErr(t, err)
+	fakeURL := strings.Join([]string{baseurl, "file.yaml"}, "/")
+	urlparsed, err := url.Parse(fakeURL)
+	th.AssertNoErr(t, err)
+
+	th.Mux.HandleFunc(urlparsed.Path, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		w.Header().Set("Content-Type", "application/jason")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, "Fee-fi-fo-fum")
+	})
+
+	client := fakeClient{BaseClient: getHTTPClient()}
+	te := TE{
+		URL:    "file.yaml",
+		client: client,
+	}
+	err = te.Fetch()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, fakeURL, te.URL)
+	th.AssertEquals(t, "Fee-fi-fo-fum", string(te.Bin))
+}
diff --git a/openstack/orchestration/v1/stacktemplates/doc.go b/openstack/orchestration/v1/stacktemplates/doc.go
new file mode 100644
index 0000000..5af0bd6
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/doc.go
@@ -0,0 +1,8 @@
+// Package stacktemplates provides operations for working with Heat templates.
+// A Cloud Orchestration template is a portable file, written in a user-readable
+// language, that describes how a set of resources should be assembled and what
+// software should be installed in order to produce a working stack. The template
+// specifies what resources should be used, what attributes can be set, and other
+// parameters that are critical to the successful, repeatable automation of a
+// specific application stack.
+package stacktemplates
diff --git a/openstack/orchestration/v1/stacktemplates/fixtures.go b/openstack/orchestration/v1/stacktemplates/fixtures.go
new file mode 100644
index 0000000..bfcaa90
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/fixtures.go
@@ -0,0 +1,95 @@
+package stacktemplates
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+// GetExpected represents the expected object from a Get request.
+var GetExpected = "{\n  \"description\": \"Simple template to test heat commands\",\n  \"heat_template_version\": \"2013-05-23\",\n  \"parameters\": {\n    \"flavor\": {\n      \"default\": \"m1.tiny\",\n      \"type\": \"string\"\n    }\n  },\n  \"resources\": {\n    \"hello_world\": {\n      \"properties\": {\n        \"flavor\": {\n          \"get_param\": \"flavor\"\n        },\n        \"image\": \"ad091b52-742f-469e-8f3c-fd81cadf0743\",\n        \"key_name\": \"heat_key\"\n      },\n      \"type\": \"OS::Nova::Server\"\n    }\n  }\n}"
+
+// GetOutput represents the response body from a Get request.
+const GetOutput = `
+{
+  "heat_template_version": "2013-05-23",
+  "description": "Simple template to test heat commands",
+  "parameters": {
+    "flavor": {
+      "default": "m1.tiny",
+      "type": "string"
+    }
+  },
+  "resources": {
+    "hello_world": {
+      "type": "OS::Nova::Server",
+      "properties": {
+        "key_name": "heat_key",
+        "flavor": {
+          "get_param": "flavor"
+        },
+        "image": "ad091b52-742f-469e-8f3c-fd81cadf0743"
+      }
+    }
+  }
+}`
+
+// HandleGetSuccessfully creates an HTTP handler at `/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/template`
+// on the test handler mux that responds with a `Get` response.
+func HandleGetSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/stacks/postman_stack/16ef0584-4458-41eb-87c8-0dc8d5f66c87/template", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
+
+// ValidateExpected represents the expected object from a Validate request.
+var ValidateExpected = &ValidatedTemplate{
+	Description: "Simple template to test heat commands",
+	Parameters: map[string]interface{}{
+		"flavor": map[string]interface{}{
+			"Default":     "m1.tiny",
+			"Type":        "String",
+			"NoEcho":      "false",
+			"Description": "",
+			"Label":       "flavor",
+		},
+	},
+}
+
+// ValidateOutput represents the response body from a Validate request.
+const ValidateOutput = `
+{
+	"Description": "Simple template to test heat commands",
+	"Parameters": {
+		"flavor": {
+			"Default": "m1.tiny",
+			"Type": "String",
+			"NoEcho": "false",
+			"Description": "",
+			"Label": "flavor"
+		}
+	}
+}`
+
+// HandleValidateSuccessfully creates an HTTP handler at `/validate`
+// on the test handler mux that responds with a `Validate` response.
+func HandleValidateSuccessfully(t *testing.T, output string) {
+	th.Mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, output)
+	})
+}
diff --git a/openstack/orchestration/v1/stacktemplates/requests.go b/openstack/orchestration/v1/stacktemplates/requests.go
new file mode 100644
index 0000000..d248c24
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/requests.go
@@ -0,0 +1,39 @@
+package stacktemplates
+
+import "github.com/gophercloud/gophercloud"
+
+// Get retreives data for the given stack template.
+func Get(c *gophercloud.ServiceClient, stackName, stackID string) (r GetResult) {
+	_, r.Err = c.Get(getURL(c, stackName, stackID), &r.Body, nil)
+	return
+}
+
+// ValidateOptsBuilder describes struct types that can be accepted by the Validate call.
+// The ValidateOpts struct in this package does.
+type ValidateOptsBuilder interface {
+	ToStackTemplateValidateMap() (map[string]interface{}, error)
+}
+
+// ValidateOpts specifies the template validation parameters.
+type ValidateOpts struct {
+	Template    string `json:"template" or:"TemplateURL"`
+	TemplateURL string `json:"template_url" or:"Template"`
+}
+
+// ToStackTemplateValidateMap assembles a request body based on the contents of a ValidateOpts.
+func (opts ValidateOpts) ToStackTemplateValidateMap() (map[string]interface{}, error) {
+	return gophercloud.BuildRequestBody(opts, "")
+}
+
+// Validate validates the given stack template.
+func Validate(c *gophercloud.ServiceClient, opts ValidateOptsBuilder) (r ValidateResult) {
+	b, err := opts.ToStackTemplateValidateMap()
+	if err != nil {
+		r.Err = err
+		return
+	}
+	_, r.Err = c.Post(validateURL(c), b, &r.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200},
+	})
+	return
+}
diff --git a/openstack/orchestration/v1/stacktemplates/requests_test.go b/openstack/orchestration/v1/stacktemplates/requests_test.go
new file mode 100644
index 0000000..42663dc
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/requests_test.go
@@ -0,0 +1,57 @@
+package stacktemplates
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	fake "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestGetTemplate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleGetSuccessfully(t, GetOutput)
+
+	actual, err := Get(fake.ServiceClient(), "postman_stack", "16ef0584-4458-41eb-87c8-0dc8d5f66c87").Extract()
+	th.AssertNoErr(t, err)
+
+	expected := GetExpected
+	th.AssertDeepEquals(t, expected, string(actual))
+}
+
+func TestValidateTemplate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	HandleValidateSuccessfully(t, ValidateOutput)
+
+	opts := ValidateOpts{
+		Template: `{
+		  "heat_template_version": "2013-05-23",
+		  "description": "Simple template to test heat commands",
+		  "parameters": {
+		    "flavor": {
+		      "default": "m1.tiny",
+		      "type": "string"
+		    }
+		  },
+		  "resources": {
+		    "hello_world": {
+		      "type": "OS::Nova::Server",
+		      "properties": {
+		        "key_name": "heat_key",
+		        "flavor": {
+		          "get_param": "flavor"
+		        },
+		        "image": "ad091b52-742f-469e-8f3c-fd81cadf0743",
+		        "user_data": "#!/bin/bash -xv\necho \"hello world\" &gt; /root/hello-world.txt\n"
+		      }
+		    }
+		  }
+		}`,
+	}
+	actual, err := Validate(fake.ServiceClient(), opts).Extract()
+	th.AssertNoErr(t, err)
+
+	expected := ValidateExpected
+	th.AssertDeepEquals(t, expected, actual)
+}
diff --git a/openstack/orchestration/v1/stacktemplates/results.go b/openstack/orchestration/v1/stacktemplates/results.go
new file mode 100644
index 0000000..bca959b
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/results.go
@@ -0,0 +1,44 @@
+package stacktemplates
+
+import (
+	"encoding/json"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// GetResult represents the result of a Get operation.
+type GetResult struct {
+	gophercloud.Result
+}
+
+// Extract returns the JSON template and is called after a Get operation.
+func (r GetResult) Extract() ([]byte, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+	template, err := json.MarshalIndent(r.Body, "", "  ")
+	if err != nil {
+		return nil, err
+	}
+	return template, nil
+}
+
+// ValidatedTemplate represents the parsed object returned from a Validate request.
+type ValidatedTemplate struct {
+	Description     string                 `json:"Description"`
+	Parameters      map[string]interface{} `json:"Parameters"`
+	ParameterGroups map[string]interface{} `json:"ParameterGroups"`
+}
+
+// ValidateResult represents the result of a Validate operation.
+type ValidateResult struct {
+	gophercloud.Result
+}
+
+// Extract returns a pointer to a ValidatedTemplate object and is called after a
+// Validate operation.
+func (r ValidateResult) Extract() (*ValidatedTemplate, error) {
+	var s *ValidatedTemplate
+	err := r.ExtractInto(&s)
+	return s, err
+}
diff --git a/openstack/orchestration/v1/stacktemplates/urls.go b/openstack/orchestration/v1/stacktemplates/urls.go
new file mode 100644
index 0000000..aed6b4b
--- /dev/null
+++ b/openstack/orchestration/v1/stacktemplates/urls.go
@@ -0,0 +1,11 @@
+package stacktemplates
+
+import "github.com/gophercloud/gophercloud"
+
+func getURL(c *gophercloud.ServiceClient, stackName, stackID string) string {
+	return c.ServiceURL("stacks", stackName, stackID, "template")
+}
+
+func validateURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("validate")
+}
diff --git a/openstack/utils/choose_version.go b/openstack/utils/choose_version.go
new file mode 100644
index 0000000..c605d08
--- /dev/null
+++ b/openstack/utils/choose_version.go
@@ -0,0 +1,114 @@
+package utils
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// Version is a supported API version, corresponding to a vN package within the appropriate service.
+type Version struct {
+	ID       string
+	Suffix   string
+	Priority int
+}
+
+var goodStatus = map[string]bool{
+	"current":   true,
+	"supported": true,
+	"stable":    true,
+}
+
+// ChooseVersion queries the base endpoint of an API to choose the most recent non-experimental alternative from a service's
+// published versions.
+// It returns the highest-Priority Version among the alternatives that are provided, as well as its corresponding endpoint.
+func ChooseVersion(client *gophercloud.ProviderClient, recognized []*Version) (*Version, string, error) {
+	type linkResp struct {
+		Href string `json:"href"`
+		Rel  string `json:"rel"`
+	}
+
+	type valueResp struct {
+		ID     string     `json:"id"`
+		Status string     `json:"status"`
+		Links  []linkResp `json:"links"`
+	}
+
+	type versionsResp struct {
+		Values []valueResp `json:"values"`
+	}
+
+	type response struct {
+		Versions versionsResp `json:"versions"`
+	}
+
+	normalize := func(endpoint string) string {
+		if !strings.HasSuffix(endpoint, "/") {
+			return endpoint + "/"
+		}
+		return endpoint
+	}
+	identityEndpoint := normalize(client.IdentityEndpoint)
+
+	// If a full endpoint is specified, check version suffixes for a match first.
+	for _, v := range recognized {
+		if strings.HasSuffix(identityEndpoint, v.Suffix) {
+			return v, identityEndpoint, nil
+		}
+	}
+
+	var resp response
+	_, err := client.Request("GET", client.IdentityBase, &gophercloud.RequestOpts{
+		JSONResponse: &resp,
+		OkCodes:      []int{200, 300},
+	})
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	byID := make(map[string]*Version)
+	for _, version := range recognized {
+		byID[version.ID] = version
+	}
+
+	var highest *Version
+	var endpoint string
+
+	for _, value := range resp.Versions.Values {
+		href := ""
+		for _, link := range value.Links {
+			if link.Rel == "self" {
+				href = normalize(link.Href)
+			}
+		}
+
+		if matching, ok := byID[value.ID]; ok {
+			// Prefer a version that exactly matches the provided endpoint.
+			if href == identityEndpoint {
+				if href == "" {
+					return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", value.ID, client.IdentityBase)
+				}
+				return matching, href, nil
+			}
+
+			// Otherwise, find the highest-priority version with a whitelisted status.
+			if goodStatus[strings.ToLower(value.Status)] {
+				if highest == nil || matching.Priority > highest.Priority {
+					highest = matching
+					endpoint = href
+				}
+			}
+		}
+	}
+
+	if highest == nil {
+		return nil, "", fmt.Errorf("No supported version available from endpoint %s", client.IdentityBase)
+	}
+	if endpoint == "" {
+		return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", highest.ID, client.IdentityBase)
+	}
+
+	return highest, endpoint, nil
+}
diff --git a/openstack/utils/choose_version_test.go b/openstack/utils/choose_version_test.go
new file mode 100644
index 0000000..9f2f363
--- /dev/null
+++ b/openstack/utils/choose_version_test.go
@@ -0,0 +1,118 @@
+package utils
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+func setupVersionHandler() {
+	testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "stable",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s/v3.0", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s/v2.0", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, testhelper.Server.URL, testhelper.Server.URL)
+	})
+}
+
+func TestChooseVersion(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	setupVersionHandler()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "blarg"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "hargl"}
+
+	c := &gophercloud.ProviderClient{
+		IdentityBase:     testhelper.Endpoint(),
+		IdentityEndpoint: "",
+	}
+	v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v3 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v3, v)
+	}
+
+	expected := testhelper.Endpoint() + "v3.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
+
+func TestChooseVersionOpinionatedLink(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	setupVersionHandler()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "nope"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "northis"}
+
+	c := &gophercloud.ProviderClient{
+		IdentityBase:     testhelper.Endpoint(),
+		IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+	}
+	v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v2 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+	}
+
+	expected := testhelper.Endpoint() + "v2.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
+
+func TestChooseVersionFromSuffix(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "/v2.0/"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "/v3.0/"}
+
+	c := &gophercloud.ProviderClient{
+		IdentityBase:     testhelper.Endpoint(),
+		IdentityEndpoint: testhelper.Endpoint() + "v2.0/",
+	}
+	v, endpoint, err := ChooseVersion(c, []*Version{v2, v3})
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v2 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+	}
+
+	expected := testhelper.Endpoint() + "v2.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
diff --git a/pagination/http.go b/pagination/http.go
new file mode 100644
index 0000000..cb4b4ae
--- /dev/null
+++ b/pagination/http.go
@@ -0,0 +1,60 @@
+package pagination
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// PageResult stores the HTTP response that returned the current page of results.
+type PageResult struct {
+	gophercloud.Result
+	url.URL
+}
+
+// PageResultFrom parses an HTTP response as JSON and returns a PageResult containing the
+// results, interpreting it as JSON if the content type indicates.
+func PageResultFrom(resp *http.Response) (PageResult, error) {
+	var parsedBody interface{}
+
+	defer resp.Body.Close()
+	rawBody, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return PageResult{}, err
+	}
+
+	if strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
+		err = json.Unmarshal(rawBody, &parsedBody)
+		if err != nil {
+			return PageResult{}, err
+		}
+	} else {
+		parsedBody = rawBody
+	}
+
+	return PageResultFromParsed(resp, parsedBody), err
+}
+
+// PageResultFromParsed constructs a PageResult from an HTTP response that has already had its
+// body parsed as JSON (and closed).
+func PageResultFromParsed(resp *http.Response, body interface{}) PageResult {
+	return PageResult{
+		Result: gophercloud.Result{
+			Body:   body,
+			Header: resp.Header,
+		},
+		URL: *resp.Request.URL,
+	}
+}
+
+// Request performs an HTTP request and extracts the http.Response from the result.
+func Request(client *gophercloud.ServiceClient, headers map[string]string, url string) (*http.Response, error) {
+	return client.Get(url, nil, &gophercloud.RequestOpts{
+		MoreHeaders: headers,
+		OkCodes:     []int{200, 204},
+	})
+}
diff --git a/pagination/linked.go b/pagination/linked.go
new file mode 100644
index 0000000..3656fb7
--- /dev/null
+++ b/pagination/linked.go
@@ -0,0 +1,92 @@
+package pagination
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// LinkedPageBase may be embedded to implement a page that provides navigational "Next" and "Previous" links within its result.
+type LinkedPageBase struct {
+	PageResult
+
+	// LinkPath lists the keys that should be traversed within a response to arrive at the "next" pointer.
+	// If any link along the path is missing, an empty URL will be returned.
+	// If any link results in an unexpected value type, an error will be returned.
+	// When left as "nil", []string{"links", "next"} will be used as a default.
+	LinkPath []string
+}
+
+// NextPageURL extracts the pagination structure from a JSON response and returns the "next" link, if one is present.
+// It assumes that the links are available in a "links" element of the top-level response object.
+// If this is not the case, override NextPageURL on your result type.
+func (current LinkedPageBase) NextPageURL() (string, error) {
+	var path []string
+	var key string
+
+	if current.LinkPath == nil {
+		path = []string{"links", "next"}
+	} else {
+		path = current.LinkPath
+	}
+
+	submap, ok := current.Body.(map[string]interface{})
+	if !ok {
+		err := gophercloud.ErrUnexpectedType{}
+		err.Expected = "map[string]interface{}"
+		err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+		return "", err
+	}
+
+	for {
+		key, path = path[0], path[1:len(path)]
+
+		value, ok := submap[key]
+		if !ok {
+			return "", nil
+		}
+
+		if len(path) > 0 {
+			submap, ok = value.(map[string]interface{})
+			if !ok {
+				err := gophercloud.ErrUnexpectedType{}
+				err.Expected = "map[string]interface{}"
+				err.Actual = fmt.Sprintf("%v", reflect.TypeOf(value))
+				return "", err
+			}
+		} else {
+			if value == nil {
+				// Actual null element.
+				return "", nil
+			}
+
+			url, ok := value.(string)
+			if !ok {
+				err := gophercloud.ErrUnexpectedType{}
+				err.Expected = "string"
+				err.Actual = fmt.Sprintf("%v", reflect.TypeOf(value))
+				return "", err
+			}
+
+			return url, nil
+		}
+	}
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current LinkedPageBase) IsEmpty() (bool, error) {
+	if b, ok := current.Body.([]interface{}); ok {
+		return len(b) == 0, nil
+	}
+	err := gophercloud.ErrUnexpectedType{}
+	err.Expected = "[]interface{}"
+	err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+	return true, err
+}
+
+// GetBody returns the linked page's body. This method is needed to satisfy the
+// Page interface.
+func (current LinkedPageBase) GetBody() interface{} {
+	return current.Body
+}
diff --git a/pagination/linked_test.go b/pagination/linked_test.go
new file mode 100644
index 0000000..67e6e3c
--- /dev/null
+++ b/pagination/linked_test.go
@@ -0,0 +1,111 @@
+package pagination
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+// LinkedPager sample and test cases.
+
+type LinkedPageResult struct {
+	LinkedPageBase
+}
+
+func (r LinkedPageResult) IsEmpty() (bool, error) {
+	is, err := ExtractLinkedInts(r)
+	return len(is) == 0, err
+}
+
+func ExtractLinkedInts(r Page) ([]int, error) {
+	var s struct {
+		Ints []int `json:"ints"`
+	}
+	err := (r.(LinkedPageResult)).ExtractInto(&s)
+	return s.Ints, err
+}
+
+func createLinked(t *testing.T) Pager {
+	testhelper.SetupHTTP()
+
+	testhelper.Mux.HandleFunc("/page1", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{ "ints": [1, 2, 3], "links": { "next": "%s/page2" } }`, testhelper.Server.URL)
+	})
+
+	testhelper.Mux.HandleFunc("/page2", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{ "ints": [4, 5, 6], "links": { "next": "%s/page3" } }`, testhelper.Server.URL)
+	})
+
+	testhelper.Mux.HandleFunc("/page3", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{ "ints": [7, 8, 9], "links": { "next": null } }`)
+	})
+
+	client := createClient()
+
+	createPage := func(r PageResult) Page {
+		return LinkedPageResult{LinkedPageBase{PageResult: r}}
+	}
+
+	return NewPager(client, testhelper.Server.URL+"/page1", createPage)
+}
+
+func TestEnumerateLinked(t *testing.T) {
+	pager := createLinked(t)
+	defer testhelper.TeardownHTTP()
+
+	callCount := 0
+	err := pager.EachPage(func(page Page) (bool, error) {
+		actual, err := ExtractLinkedInts(page)
+		if err != nil {
+			return false, err
+		}
+
+		t.Logf("Handler invoked with %v", actual)
+
+		var expected []int
+		switch callCount {
+		case 0:
+			expected = []int{1, 2, 3}
+		case 1:
+			expected = []int{4, 5, 6}
+		case 2:
+			expected = []int{7, 8, 9}
+		default:
+			t.Fatalf("Unexpected call count: %d", callCount)
+			return false, nil
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Call %d: Expected %#v, but was %#v", callCount, expected, actual)
+		}
+
+		callCount++
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("Unexpected error for page iteration: %v", err)
+	}
+
+	if callCount != 3 {
+		t.Errorf("Expected 3 calls, but was %d", callCount)
+	}
+}
+
+func TestAllPagesLinked(t *testing.T) {
+	pager := createLinked(t)
+	defer testhelper.TeardownHTTP()
+
+	page, err := pager.AllPages()
+	testhelper.AssertNoErr(t, err)
+
+	expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
+	actual, err := ExtractLinkedInts(page)
+	testhelper.AssertNoErr(t, err)
+	testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/pagination/marker.go b/pagination/marker.go
new file mode 100644
index 0000000..52e53ba
--- /dev/null
+++ b/pagination/marker.go
@@ -0,0 +1,58 @@
+package pagination
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// MarkerPage is a stricter Page interface that describes additional functionality required for use with NewMarkerPager.
+// For convenience, embed the MarkedPageBase struct.
+type MarkerPage interface {
+	Page
+
+	// LastMarker returns the last "marker" value on this page.
+	LastMarker() (string, error)
+}
+
+// MarkerPageBase is a page in a collection that's paginated by "limit" and "marker" query parameters.
+type MarkerPageBase struct {
+	PageResult
+
+	// Owner is a reference to the embedding struct.
+	Owner MarkerPage
+}
+
+// NextPageURL generates the URL for the page of results after this one.
+func (current MarkerPageBase) NextPageURL() (string, error) {
+	currentURL := current.URL
+
+	mark, err := current.Owner.LastMarker()
+	if err != nil {
+		return "", err
+	}
+
+	q := currentURL.Query()
+	q.Set("marker", mark)
+	currentURL.RawQuery = q.Encode()
+
+	return currentURL.String(), nil
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current MarkerPageBase) IsEmpty() (bool, error) {
+	if b, ok := current.Body.([]interface{}); ok {
+		return len(b) == 0, nil
+	}
+	err := gophercloud.ErrUnexpectedType{}
+	err.Expected = "[]interface{}"
+	err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+	return true, err
+}
+
+// GetBody returns the linked page's body. This method is needed to satisfy the
+// Page interface.
+func (current MarkerPageBase) GetBody() interface{} {
+	return current.Body
+}
diff --git a/pagination/marker_test.go b/pagination/marker_test.go
new file mode 100644
index 0000000..4ade8d3
--- /dev/null
+++ b/pagination/marker_test.go
@@ -0,0 +1,126 @@
+package pagination
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+// MarkerPager sample and test cases.
+
+type MarkerPageResult struct {
+	MarkerPageBase
+}
+
+func (r MarkerPageResult) IsEmpty() (bool, error) {
+	results, err := ExtractMarkerStrings(r)
+	if err != nil {
+		return true, err
+	}
+	return len(results) == 0, err
+}
+
+func (r MarkerPageResult) LastMarker() (string, error) {
+	results, err := ExtractMarkerStrings(r)
+	if err != nil {
+		return "", err
+	}
+	if len(results) == 0 {
+		return "", nil
+	}
+	return results[len(results)-1], nil
+}
+
+func createMarkerPaged(t *testing.T) Pager {
+	testhelper.SetupHTTP()
+
+	testhelper.Mux.HandleFunc("/page", func(w http.ResponseWriter, r *http.Request) {
+		r.ParseForm()
+		ms := r.Form["marker"]
+		switch {
+		case len(ms) == 0:
+			fmt.Fprintf(w, "aaa\nbbb\nccc")
+		case len(ms) == 1 && ms[0] == "ccc":
+			fmt.Fprintf(w, "ddd\neee\nfff")
+		case len(ms) == 1 && ms[0] == "fff":
+			fmt.Fprintf(w, "ggg\nhhh\niii")
+		case len(ms) == 1 && ms[0] == "iii":
+			w.WriteHeader(http.StatusNoContent)
+		default:
+			t.Errorf("Request with unexpected marker: [%v]", ms)
+		}
+	})
+
+	client := createClient()
+
+	createPage := func(r PageResult) Page {
+		p := MarkerPageResult{MarkerPageBase{PageResult: r}}
+		p.MarkerPageBase.Owner = p
+		return p
+	}
+
+	return NewPager(client, testhelper.Server.URL+"/page", createPage)
+}
+
+func ExtractMarkerStrings(page Page) ([]string, error) {
+	content := page.(MarkerPageResult).Body.([]uint8)
+	parts := strings.Split(string(content), "\n")
+	results := make([]string, 0, len(parts))
+	for _, part := range parts {
+		if len(part) > 0 {
+			results = append(results, part)
+		}
+	}
+	return results, nil
+}
+
+func TestEnumerateMarker(t *testing.T) {
+	pager := createMarkerPaged(t)
+	defer testhelper.TeardownHTTP()
+
+	callCount := 0
+	err := pager.EachPage(func(page Page) (bool, error) {
+		actual, err := ExtractMarkerStrings(page)
+		if err != nil {
+			return false, err
+		}
+
+		t.Logf("Handler invoked with %v", actual)
+
+		var expected []string
+		switch callCount {
+		case 0:
+			expected = []string{"aaa", "bbb", "ccc"}
+		case 1:
+			expected = []string{"ddd", "eee", "fff"}
+		case 2:
+			expected = []string{"ggg", "hhh", "iii"}
+		default:
+			t.Fatalf("Unexpected call count: %d", callCount)
+			return false, nil
+		}
+
+		testhelper.CheckDeepEquals(t, expected, actual)
+
+		callCount++
+		return true, nil
+	})
+	testhelper.AssertNoErr(t, err)
+	testhelper.AssertEquals(t, callCount, 3)
+}
+
+func TestAllPagesMarker(t *testing.T) {
+	pager := createMarkerPaged(t)
+	defer testhelper.TeardownHTTP()
+
+	page, err := pager.AllPages()
+	testhelper.AssertNoErr(t, err)
+
+	expected := []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii"}
+	actual, err := ExtractMarkerStrings(page)
+	testhelper.AssertNoErr(t, err)
+	testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/pagination/pager.go b/pagination/pager.go
new file mode 100644
index 0000000..1d3e907
--- /dev/null
+++ b/pagination/pager.go
@@ -0,0 +1,229 @@
+package pagination
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+var (
+	// ErrPageNotAvailable is returned from a Pager when a next or previous page is requested, but does not exist.
+	ErrPageNotAvailable = errors.New("The requested page does not exist.")
+)
+
+// Page must be satisfied by the result type of any resource collection.
+// It allows clients to interact with the resource uniformly, regardless of whether or not or how it's paginated.
+// Generally, rather than implementing this interface directly, implementors should embed one of the concrete PageBase structs,
+// instead.
+// Depending on the pagination strategy of a particular resource, there may be an additional subinterface that the result type
+// will need to implement.
+type Page interface {
+
+	// NextPageURL generates the URL for the page of data that follows this collection.
+	// Return "" if no such page exists.
+	NextPageURL() (string, error)
+
+	// IsEmpty returns true if this Page has no items in it.
+	IsEmpty() (bool, error)
+
+	// GetBody returns the Page Body. This is used in the `AllPages` method.
+	GetBody() interface{}
+}
+
+// Pager knows how to advance through a specific resource collection, one page at a time.
+type Pager struct {
+	client *gophercloud.ServiceClient
+
+	initialURL string
+
+	createPage func(r PageResult) Page
+
+	Err error
+
+	// Headers supplies additional HTTP headers to populate on each paged request.
+	Headers map[string]string
+}
+
+// NewPager constructs a manually-configured pager.
+// Supply the URL for the first page, a function that requests a specific page given a URL, and a function that counts a page.
+func NewPager(client *gophercloud.ServiceClient, initialURL string, createPage func(r PageResult) Page) Pager {
+	return Pager{
+		client:     client,
+		initialURL: initialURL,
+		createPage: createPage,
+	}
+}
+
+// WithPageCreator returns a new Pager that substitutes a different page creation function. This is
+// useful for overriding List functions in delegation.
+func (p Pager) WithPageCreator(createPage func(r PageResult) Page) Pager {
+	return Pager{
+		client:     p.client,
+		initialURL: p.initialURL,
+		createPage: createPage,
+	}
+}
+
+func (p Pager) fetchNextPage(url string) (Page, error) {
+	resp, err := Request(p.client, p.Headers, url)
+	if err != nil {
+		return nil, err
+	}
+
+	remembered, err := PageResultFrom(resp)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.createPage(remembered), nil
+}
+
+// EachPage iterates over each page returned by a Pager, yielding one at a time to a handler function.
+// Return "false" from the handler to prematurely stop iterating.
+func (p Pager) EachPage(handler func(Page) (bool, error)) error {
+	if p.Err != nil {
+		return p.Err
+	}
+	currentURL := p.initialURL
+	for {
+		currentPage, err := p.fetchNextPage(currentURL)
+		if err != nil {
+			return err
+		}
+
+		empty, err := currentPage.IsEmpty()
+		if err != nil {
+			return err
+		}
+		if empty {
+			return nil
+		}
+
+		ok, err := handler(currentPage)
+		if err != nil {
+			return err
+		}
+		if !ok {
+			return nil
+		}
+
+		currentURL, err = currentPage.NextPageURL()
+		if err != nil {
+			return err
+		}
+		if currentURL == "" {
+			return nil
+		}
+	}
+}
+
+// AllPages returns all the pages from a `List` operation in a single page,
+// allowing the user to retrieve all the pages at once.
+func (p Pager) AllPages() (Page, error) {
+	// pagesSlice holds all the pages until they get converted into as Page Body.
+	var pagesSlice []interface{}
+	// body will contain the final concatenated Page body.
+	var body reflect.Value
+
+	// Grab a test page to ascertain the page body type.
+	testPage, err := p.fetchNextPage(p.initialURL)
+	if err != nil {
+		return nil, err
+	}
+	// Store the page type so we can use reflection to create a new mega-page of
+	// that type.
+	pageType := reflect.TypeOf(testPage)
+
+	// Switch on the page body type. Recognized types are `map[string]interface{}`,
+	// `[]byte`, and `[]interface{}`.
+	switch testPage.GetBody().(type) {
+	case map[string]interface{}:
+		// key is the map key for the page body if the body type is `map[string]interface{}`.
+		var key string
+		// Iterate over the pages to concatenate the bodies.
+		err = p.EachPage(func(page Page) (bool, error) {
+			b := page.GetBody().(map[string]interface{})
+			for k := range b {
+				// If it's a linked page, we don't want the `links`, we want the other one.
+				if !strings.HasSuffix(k, "links") {
+					key = k
+				}
+			}
+			pagesSlice = append(pagesSlice, b[key].([]interface{})...)
+			return true, nil
+		})
+		if err != nil {
+			return nil, err
+		}
+		// Set body to value of type `map[string]interface{}`
+		body = reflect.MakeMap(reflect.MapOf(reflect.TypeOf(key), reflect.TypeOf(pagesSlice)))
+		body.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(pagesSlice))
+	case []byte:
+		// Iterate over the pages to concatenate the bodies.
+		err = p.EachPage(func(page Page) (bool, error) {
+			b := page.GetBody().([]byte)
+			pagesSlice = append(pagesSlice, b)
+			// seperate pages with a comma
+			pagesSlice = append(pagesSlice, []byte{10})
+			return true, nil
+		})
+		if err != nil {
+			return nil, err
+		}
+		if len(pagesSlice) > 0 {
+			// Remove the trailing comma.
+			pagesSlice = pagesSlice[:len(pagesSlice)-1]
+		}
+		var b []byte
+		// Combine the slice of slices in to a single slice.
+		for _, slice := range pagesSlice {
+			b = append(b, slice.([]byte)...)
+		}
+		// Set body to value of type `bytes`.
+		body = reflect.New(reflect.TypeOf(b)).Elem()
+		body.SetBytes(b)
+	case []interface{}:
+		// Iterate over the pages to concatenate the bodies.
+		err = p.EachPage(func(page Page) (bool, error) {
+			b := page.GetBody().([]interface{})
+			pagesSlice = append(pagesSlice, b...)
+			return true, nil
+		})
+		if err != nil {
+			return nil, err
+		}
+		// Set body to value of type `[]interface{}`
+		body = reflect.MakeSlice(reflect.TypeOf(pagesSlice), len(pagesSlice), len(pagesSlice))
+		for i, s := range pagesSlice {
+			body.Index(i).Set(reflect.ValueOf(s))
+		}
+	default:
+		err := gophercloud.ErrUnexpectedType{}
+		err.Expected = "map[string]interface{}/[]byte/[]interface{}"
+		err.Actual = fmt.Sprintf("%v", reflect.TypeOf(testPage.GetBody()))
+		return nil, err
+	}
+
+	// Each `Extract*` function is expecting a specific type of page coming back,
+	// otherwise the type assertion in those functions will fail. pageType is needed
+	// to create a type in this method that has the same type that the `Extract*`
+	// function is expecting and set the Body of that object to the concatenated
+	// pages.
+	page := reflect.New(pageType)
+	// Set the page body to be the concatenated pages.
+	page.Elem().FieldByName("Body").Set(body)
+	// Set any additional headers that were pass along. The `objectstorage` pacakge,
+	// for example, passes a Content-Type header.
+	h := make(http.Header)
+	for k, v := range p.Headers {
+		h.Add(k, v)
+	}
+	page.Elem().FieldByName("Header").Set(reflect.ValueOf(h))
+	// Type assert the page to a Page interface so that the type assertion in the
+	// `Extract*` methods will work.
+	return page.Elem().Interface().(Page), err
+}
diff --git a/pagination/pagination_test.go b/pagination/pagination_test.go
new file mode 100644
index 0000000..bd3295e
--- /dev/null
+++ b/pagination/pagination_test.go
@@ -0,0 +1,13 @@
+package pagination
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+func createClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{TokenID: "abc123"},
+		Endpoint:       testhelper.Endpoint(),
+	}
+}
diff --git a/pagination/pkg.go b/pagination/pkg.go
new file mode 100644
index 0000000..912daea
--- /dev/null
+++ b/pagination/pkg.go
@@ -0,0 +1,4 @@
+/*
+Package pagination contains utilities and convenience structs that implement common pagination idioms within OpenStack APIs.
+*/
+package pagination
diff --git a/pagination/single.go b/pagination/single.go
new file mode 100644
index 0000000..4251d64
--- /dev/null
+++ b/pagination/single.go
@@ -0,0 +1,33 @@
+package pagination
+
+import (
+	"fmt"
+	"reflect"
+
+	"github.com/gophercloud/gophercloud"
+)
+
+// SinglePageBase may be embedded in a Page that contains all of the results from an operation at once.
+type SinglePageBase PageResult
+
+// NextPageURL always returns "" to indicate that there are no more pages to return.
+func (current SinglePageBase) NextPageURL() (string, error) {
+	return "", nil
+}
+
+// IsEmpty satisifies the IsEmpty method of the Page interface
+func (current SinglePageBase) IsEmpty() (bool, error) {
+	if b, ok := current.Body.([]interface{}); ok {
+		return len(b) == 0, nil
+	}
+	err := gophercloud.ErrUnexpectedType{}
+	err.Expected = "[]interface{}"
+	err.Actual = fmt.Sprintf("%v", reflect.TypeOf(current.Body))
+	return true, err
+}
+
+// GetBody returns the single page's body. This method is needed to satisfy the
+// Page interface.
+func (current SinglePageBase) GetBody() interface{} {
+	return current.Body
+}
diff --git a/pagination/single_test.go b/pagination/single_test.go
new file mode 100644
index 0000000..2a9466c
--- /dev/null
+++ b/pagination/single_test.go
@@ -0,0 +1,78 @@
+package pagination
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+// SinglePage sample and test cases.
+
+type SinglePageResult struct {
+	SinglePageBase
+}
+
+func (r SinglePageResult) IsEmpty() (bool, error) {
+	is, err := ExtractSingleInts(r)
+	if err != nil {
+		return true, err
+	}
+	return len(is) == 0, nil
+}
+
+func ExtractSingleInts(r Page) ([]int, error) {
+	var s struct {
+		Ints []int `json:"ints"`
+	}
+	err := (r.(SinglePageResult)).ExtractInto(&s)
+	return s.Ints, err
+}
+
+func setupSinglePaged() Pager {
+	testhelper.SetupHTTP()
+	client := createClient()
+
+	testhelper.Mux.HandleFunc("/only", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `{ "ints": [1, 2, 3] }`)
+	})
+
+	createPage := func(r PageResult) Page {
+		return SinglePageResult{SinglePageBase(r)}
+	}
+
+	return NewPager(client, testhelper.Server.URL+"/only", createPage)
+}
+
+func TestEnumerateSinglePaged(t *testing.T) {
+	callCount := 0
+	pager := setupSinglePaged()
+	defer testhelper.TeardownHTTP()
+
+	err := pager.EachPage(func(page Page) (bool, error) {
+		callCount++
+
+		expected := []int{1, 2, 3}
+		actual, err := ExtractSingleInts(page)
+		testhelper.AssertNoErr(t, err)
+		testhelper.CheckDeepEquals(t, expected, actual)
+		return true, nil
+	})
+	testhelper.CheckNoErr(t, err)
+	testhelper.CheckEquals(t, 1, callCount)
+}
+
+func TestAllPagesSingle(t *testing.T) {
+	pager := setupSinglePaged()
+	defer testhelper.TeardownHTTP()
+
+	page, err := pager.AllPages()
+	testhelper.AssertNoErr(t, err)
+
+	expected := []int{1, 2, 3}
+	actual, err := ExtractSingleInts(page)
+	testhelper.AssertNoErr(t, err)
+	testhelper.CheckDeepEquals(t, expected, actual)
+}
diff --git a/params.go b/params.go
new file mode 100644
index 0000000..b7f9508
--- /dev/null
+++ b/params.go
@@ -0,0 +1,441 @@
+package gophercloud
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"reflect"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// BuildRequestBody builds a map[string]interface from the given `struct`. If
+// parent is not the empty string, the final map[string]interface returned will
+// encapsulate the built one
+//
+func BuildRequestBody(opts interface{}, parent string) (map[string]interface{}, error) {
+	optsValue := reflect.ValueOf(opts)
+	if optsValue.Kind() == reflect.Ptr {
+		optsValue = optsValue.Elem()
+	}
+
+	optsType := reflect.TypeOf(opts)
+	if optsType.Kind() == reflect.Ptr {
+		optsType = optsType.Elem()
+	}
+
+	optsMap := make(map[string]interface{})
+	if optsValue.Kind() == reflect.Struct {
+		//fmt.Printf("optsValue.Kind() is a reflect.Struct: %+v\n", optsValue.Kind())
+		for i := 0; i < optsValue.NumField(); i++ {
+			v := optsValue.Field(i)
+			f := optsType.Field(i)
+
+			if f.Name != strings.Title(f.Name) {
+				//fmt.Printf("Skipping field: %s...\n", f.Name)
+				continue
+			}
+
+			//fmt.Printf("Starting on field: %s...\n", f.Name)
+
+			zero := isZero(v)
+			//fmt.Printf("v is zero?: %v\n", zero)
+
+			// if the field has a required tag that's set to "true"
+			if requiredTag := f.Tag.Get("required"); requiredTag == "true" {
+				//fmt.Printf("Checking required field [%s]:\n\tv: %+v\n\tisZero:%v\n", f.Name, v.Interface(), zero)
+				// if the field's value is zero, return a missing-argument error
+				if zero {
+					// if the field has a 'required' tag, it can't have a zero-value
+					err := ErrMissingInput{}
+					err.Argument = f.Name
+					return nil, err
+				}
+			}
+
+			if xorTag := f.Tag.Get("xor"); xorTag != "" {
+				//fmt.Printf("Checking `xor` tag for field [%s] with value %+v:\n\txorTag: %s\n", f.Name, v, xorTag)
+				xorField := optsValue.FieldByName(xorTag)
+				var xorFieldIsZero bool
+				if reflect.ValueOf(xorField.Interface()) == reflect.Zero(xorField.Type()) {
+					xorFieldIsZero = true
+				} else {
+					if xorField.Kind() == reflect.Ptr {
+						xorField = xorField.Elem()
+					}
+					xorFieldIsZero = isZero(xorField)
+				}
+				if !(zero != xorFieldIsZero) {
+					err := ErrMissingInput{}
+					err.Argument = fmt.Sprintf("%s/%s", f.Name, xorTag)
+					err.Info = fmt.Sprintf("Exactly one of %s and %s must be provided", f.Name, xorTag)
+					return nil, err
+				}
+			}
+
+			if orTag := f.Tag.Get("or"); orTag != "" {
+				//fmt.Printf("Checking `or` tag for field with:\n\tname: %+v\n\torTag:%s\n", f.Name, orTag)
+				//fmt.Printf("field is zero?: %v\n", zero)
+				if zero {
+					orField := optsValue.FieldByName(orTag)
+					var orFieldIsZero bool
+					if reflect.ValueOf(orField.Interface()) == reflect.Zero(orField.Type()) {
+						orFieldIsZero = true
+					} else {
+						if orField.Kind() == reflect.Ptr {
+							orField = orField.Elem()
+						}
+						orFieldIsZero = isZero(orField)
+					}
+					if orFieldIsZero {
+						err := ErrMissingInput{}
+						err.Argument = fmt.Sprintf("%s/%s", f.Name, orTag)
+						err.Info = fmt.Sprintf("At least one of %s and %s must be provided", f.Name, orTag)
+						return nil, err
+					}
+				}
+			}
+
+			if v.Kind() == reflect.Struct || (v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Struct) {
+				if zero {
+					//fmt.Printf("value before change: %+v\n", optsValue.Field(i))
+					if jsonTag := f.Tag.Get("json"); jsonTag != "" {
+						jsonTagPieces := strings.Split(jsonTag, ",")
+						if len(jsonTagPieces) > 1 && jsonTagPieces[1] == "omitempty" {
+							if v.CanSet() {
+								if !v.IsNil() {
+									if v.Kind() == reflect.Ptr {
+										v.Set(reflect.Zero(v.Type()))
+									}
+								}
+								//fmt.Printf("value after change: %+v\n", optsValue.Field(i))
+							}
+						}
+					}
+					continue
+				}
+
+				//fmt.Printf("Calling BuildRequestBody with:\n\tv: %+v\n\tf.Name:%s\n", v.Interface(), f.Name)
+				_, err := BuildRequestBody(v.Interface(), f.Name)
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+
+		//fmt.Printf("opts: %+v \n", opts)
+
+		b, err := json.Marshal(opts)
+		if err != nil {
+			return nil, err
+		}
+
+		//fmt.Printf("string(b): %s\n", string(b))
+
+		err = json.Unmarshal(b, &optsMap)
+		if err != nil {
+			return nil, err
+		}
+
+		//fmt.Printf("optsMap: %+v\n", optsMap)
+
+		if parent != "" {
+			optsMap = map[string]interface{}{parent: optsMap}
+		}
+		//fmt.Printf("optsMap after parent added: %+v\n", optsMap)
+		return optsMap, nil
+	}
+	// Return an error if the underlying type of 'opts' isn't a struct.
+	return nil, fmt.Errorf("Options type is not a struct.")
+}
+
+// EnabledState is a convenience type, mostly used in Create and Update
+// operations. Because the zero value of a bool is FALSE, we need to use a
+// pointer instead to indicate zero-ness.
+type EnabledState *bool
+
+// Convenience vars for EnabledState values.
+var (
+	iTrue  = true
+	iFalse = false
+
+	Enabled  EnabledState = &iTrue
+	Disabled EnabledState = &iFalse
+)
+
+// IPVersion is a type for the possible IP address versions. Valid instances
+// are IPv4 and IPv6
+type IPVersion int
+
+const (
+	// IPv4 is used for IP version 4 addresses
+	IPv4 IPVersion = 4
+	// IPv6 is used for IP version 6 addresses
+	IPv6 IPVersion = 6
+)
+
+// IntToPointer is a function for converting integers into integer pointers.
+// This is useful when passing in options to operations.
+func IntToPointer(i int) *int {
+	return &i
+}
+
+/*
+MaybeString is an internal function to be used by request methods in individual
+resource packages.
+
+It takes a string that might be a zero value and returns either a pointer to its
+address or nil. This is useful for allowing users to conveniently omit values
+from an options struct by leaving them zeroed, but still pass nil to the JSON
+serializer so they'll be omitted from the request body.
+*/
+func MaybeString(original string) *string {
+	if original != "" {
+		return &original
+	}
+	return nil
+}
+
+/*
+MaybeInt is an internal function to be used by request methods in individual
+resource packages.
+
+Like MaybeString, it accepts an int that may or may not be a zero value, and
+returns either a pointer to its address or nil. It's intended to hint that the
+JSON serializer should omit its field.
+*/
+func MaybeInt(original int) *int {
+	if original != 0 {
+		return &original
+	}
+	return nil
+}
+
+/*
+func isUnderlyingStructZero(v reflect.Value) bool {
+	switch v.Kind() {
+	case reflect.Ptr:
+		return isUnderlyingStructZero(v.Elem())
+	default:
+		return isZero(v)
+	}
+}
+*/
+
+var t time.Time
+
+func isZero(v reflect.Value) bool {
+	//fmt.Printf("\n\nchecking isZero for value: %+v\n", v)
+	switch v.Kind() {
+	case reflect.Ptr:
+		if v.IsNil() {
+			return true
+		}
+		return isZero(v.Elem())
+	case reflect.Func, reflect.Map, reflect.Slice:
+		return v.IsNil()
+	case reflect.Array:
+		z := true
+		for i := 0; i < v.Len(); i++ {
+			z = z && isZero(v.Index(i))
+		}
+		return z
+	case reflect.Struct:
+		if v.Type() == reflect.TypeOf(t) {
+			if v.Interface().(time.Time).IsZero() {
+				return true
+			}
+			return false
+		}
+		z := true
+		for i := 0; i < v.NumField(); i++ {
+			z = z && isZero(v.Field(i))
+		}
+		return z
+	}
+	// Compare other types directly:
+	z := reflect.Zero(v.Type())
+	//fmt.Printf("zero type for value: %+v\n\n\n", z)
+	return v.Interface() == z.Interface()
+}
+
+/*
+BuildQueryString is an internal function to be used by request methods in
+individual resource packages.
+
+It accepts a tagged structure and expands it into a URL struct. Field names are
+converted into query parameters based on a "q" tag. For example:
+
+	type struct Something {
+	   Bar string `q:"x_bar"`
+	   Baz int    `q:"lorem_ipsum"`
+	}
+
+	instance := Something{
+	   Bar: "AAA",
+	   Baz: "BBB",
+	}
+
+will be converted into "?x_bar=AAA&lorem_ipsum=BBB".
+
+The struct's fields may be strings, integers, or boolean values. Fields left at
+their type's zero value will be omitted from the query.
+*/
+func BuildQueryString(opts interface{}) (*url.URL, error) {
+	optsValue := reflect.ValueOf(opts)
+	if optsValue.Kind() == reflect.Ptr {
+		optsValue = optsValue.Elem()
+	}
+
+	optsType := reflect.TypeOf(opts)
+	if optsType.Kind() == reflect.Ptr {
+		optsType = optsType.Elem()
+	}
+
+	params := url.Values{}
+
+	if optsValue.Kind() == reflect.Struct {
+		for i := 0; i < optsValue.NumField(); i++ {
+			v := optsValue.Field(i)
+			f := optsType.Field(i)
+			qTag := f.Tag.Get("q")
+
+			// if the field has a 'q' tag, it goes in the query string
+			if qTag != "" {
+				tags := strings.Split(qTag, ",")
+
+				// if the field is set, add it to the slice of query pieces
+				if !isZero(v) {
+					switch v.Kind() {
+					case reflect.String:
+						params.Add(tags[0], v.String())
+					case reflect.Int:
+						params.Add(tags[0], strconv.FormatInt(v.Int(), 10))
+					case reflect.Bool:
+						params.Add(tags[0], strconv.FormatBool(v.Bool()))
+					case reflect.Slice:
+						switch v.Type().Elem() {
+						case reflect.TypeOf(0):
+							for i := 0; i < v.Len(); i++ {
+								params.Add(tags[0], strconv.FormatInt(v.Index(i).Int(), 10))
+							}
+						default:
+							for i := 0; i < v.Len(); i++ {
+								params.Add(tags[0], v.Index(i).String())
+							}
+						}
+					}
+				} else {
+					// Otherwise, the field is not set.
+					if len(tags) == 2 && tags[1] == "required" {
+						// And the field is required. Return an error.
+						return nil, fmt.Errorf("Required query parameter [%s] not set.", f.Name)
+					}
+				}
+			}
+		}
+
+		return &url.URL{RawQuery: params.Encode()}, nil
+	}
+	// Return an error if the underlying type of 'opts' isn't a struct.
+	return nil, fmt.Errorf("Options type is not a struct.")
+}
+
+/*
+BuildHeaders is an internal function to be used by request methods in
+individual resource packages.
+
+It accepts an arbitrary tagged structure and produces a string map that's
+suitable for use as the HTTP headers of an outgoing request. Field names are
+mapped to header names based in "h" tags.
+
+  type struct Something {
+    Bar string `h:"x_bar"`
+    Baz int    `h:"lorem_ipsum"`
+  }
+
+  instance := Something{
+    Bar: "AAA",
+    Baz: "BBB",
+  }
+
+will be converted into:
+
+  map[string]string{
+    "x_bar": "AAA",
+    "lorem_ipsum": "BBB",
+  }
+
+Untagged fields and fields left at their zero values are skipped. Integers,
+booleans and string values are supported.
+*/
+func BuildHeaders(opts interface{}) (map[string]string, error) {
+	optsValue := reflect.ValueOf(opts)
+	if optsValue.Kind() == reflect.Ptr {
+		optsValue = optsValue.Elem()
+	}
+
+	optsType := reflect.TypeOf(opts)
+	if optsType.Kind() == reflect.Ptr {
+		optsType = optsType.Elem()
+	}
+
+	optsMap := make(map[string]string)
+	if optsValue.Kind() == reflect.Struct {
+		for i := 0; i < optsValue.NumField(); i++ {
+			v := optsValue.Field(i)
+			f := optsType.Field(i)
+			hTag := f.Tag.Get("h")
+
+			// if the field has a 'h' tag, it goes in the header
+			if hTag != "" {
+				tags := strings.Split(hTag, ",")
+
+				// if the field is set, add it to the slice of query pieces
+				if !isZero(v) {
+					switch v.Kind() {
+					case reflect.String:
+						optsMap[tags[0]] = v.String()
+					case reflect.Int:
+						optsMap[tags[0]] = strconv.FormatInt(v.Int(), 10)
+					case reflect.Bool:
+						optsMap[tags[0]] = strconv.FormatBool(v.Bool())
+					}
+				} else {
+					// Otherwise, the field is not set.
+					if len(tags) == 2 && tags[1] == "required" {
+						// And the field is required. Return an error.
+						return optsMap, fmt.Errorf("Required header not set.")
+					}
+				}
+			}
+
+		}
+		return optsMap, nil
+	}
+	// Return an error if the underlying type of 'opts' isn't a struct.
+	return optsMap, fmt.Errorf("Options type is not a struct.")
+}
+
+// IDSliceToQueryString takes a slice of elements and converts them into a query
+// string. For example, if name=foo and slice=[]int{20, 40, 60}, then the
+// result would be `?name=20&name=40&name=60'
+func IDSliceToQueryString(name string, ids []int) string {
+	str := ""
+	for k, v := range ids {
+		if k == 0 {
+			str += "?"
+		} else {
+			str += "&"
+		}
+		str += fmt.Sprintf("%s=%s", name, strconv.Itoa(v))
+	}
+	return str
+}
+
+// IntWithinRange returns TRUE if an integer falls within a defined range, and
+// FALSE if not.
+func IntWithinRange(val, min, max int) bool {
+	return val > min && val < max
+}
diff --git a/params_test.go b/params_test.go
new file mode 100644
index 0000000..6789a5a
--- /dev/null
+++ b/params_test.go
@@ -0,0 +1,293 @@
+package gophercloud
+
+import (
+	"net/url"
+	"reflect"
+	"testing"
+	"time"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestMaybeString(t *testing.T) {
+	testString := ""
+	var expected *string
+	actual := MaybeString(testString)
+	th.CheckDeepEquals(t, expected, actual)
+
+	testString = "carol"
+	expected = &testString
+	actual = MaybeString(testString)
+	th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestMaybeInt(t *testing.T) {
+	testInt := 0
+	var expected *int
+	actual := MaybeInt(testInt)
+	th.CheckDeepEquals(t, expected, actual)
+
+	testInt = 4
+	expected = &testInt
+	actual = MaybeInt(testInt)
+	th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestBuildQueryString(t *testing.T) {
+	type testVar string
+	opts := struct {
+		J  int       `q:"j"`
+		R  string    `q:"r,required"`
+		C  bool      `q:"c"`
+		S  []string  `q:"s"`
+		TS []testVar `q:"ts"`
+		TI []int     `q:"ti"`
+	}{
+		J:  2,
+		R:  "red",
+		C:  true,
+		S:  []string{"one", "two", "three"},
+		TS: []testVar{"a", "b"},
+		TI: []int{1, 2},
+	}
+	expected := &url.URL{RawQuery: "c=true&j=2&r=red&s=one&s=two&s=three&ti=1&ti=2&ts=a&ts=b"}
+	actual, err := BuildQueryString(&opts)
+	if err != nil {
+		t.Errorf("Error building query string: %v", err)
+	}
+	th.CheckDeepEquals(t, expected, actual)
+
+	opts = struct {
+		J  int       `q:"j"`
+		R  string    `q:"r,required"`
+		C  bool      `q:"c"`
+		S  []string  `q:"s"`
+		TS []testVar `q:"ts"`
+		TI []int     `q:"ti"`
+	}{
+		J: 2,
+		C: true,
+	}
+	_, err = BuildQueryString(&opts)
+	if err == nil {
+		t.Errorf("Expected error: 'Required field not set'")
+	}
+	th.CheckDeepEquals(t, expected, actual)
+
+	_, err = BuildQueryString(map[string]interface{}{"Number": 4})
+	if err == nil {
+		t.Errorf("Expected error: 'Options type is not a struct'")
+	}
+}
+
+func TestBuildHeaders(t *testing.T) {
+	testStruct := struct {
+		Accept string `h:"Accept"`
+		Num    int    `h:"Number,required"`
+		Style  bool   `h:"Style"`
+	}{
+		Accept: "application/json",
+		Num:    4,
+		Style:  true,
+	}
+	expected := map[string]string{"Accept": "application/json", "Number": "4", "Style": "true"}
+	actual, err := BuildHeaders(&testStruct)
+	th.CheckNoErr(t, err)
+	th.CheckDeepEquals(t, expected, actual)
+
+	testStruct.Num = 0
+	_, err = BuildHeaders(&testStruct)
+	if err == nil {
+		t.Errorf("Expected error: 'Required header not set'")
+	}
+
+	_, err = BuildHeaders(map[string]interface{}{"Number": 4})
+	if err == nil {
+		t.Errorf("Expected error: 'Options type is not a struct'")
+	}
+}
+
+func TestIsZero(t *testing.T) {
+	var testMap map[string]interface{}
+	testMapValue := reflect.ValueOf(testMap)
+	expected := true
+	actual := isZero(testMapValue)
+	th.CheckEquals(t, expected, actual)
+	testMap = map[string]interface{}{"empty": false}
+	testMapValue = reflect.ValueOf(testMap)
+	expected = false
+	actual = isZero(testMapValue)
+	th.CheckEquals(t, expected, actual)
+
+	var testArray [2]string
+	testArrayValue := reflect.ValueOf(testArray)
+	expected = true
+	actual = isZero(testArrayValue)
+	th.CheckEquals(t, expected, actual)
+	testArray = [2]string{"one", "two"}
+	testArrayValue = reflect.ValueOf(testArray)
+	expected = false
+	actual = isZero(testArrayValue)
+	th.CheckEquals(t, expected, actual)
+
+	var testStruct struct {
+		A string
+		B time.Time
+	}
+	testStructValue := reflect.ValueOf(testStruct)
+	expected = true
+	actual = isZero(testStructValue)
+	th.CheckEquals(t, expected, actual)
+	testStruct = struct {
+		A string
+		B time.Time
+	}{
+		B: time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC),
+	}
+	testStructValue = reflect.ValueOf(testStruct)
+	expected = false
+	actual = isZero(testStructValue)
+	th.CheckEquals(t, expected, actual)
+}
+
+func TestQueriesAreEscaped(t *testing.T) {
+	type foo struct {
+		Name  string `q:"something"`
+		Shape string `q:"else"`
+	}
+
+	expected := &url.URL{RawQuery: "else=Triangl+e&something=blah%2B%3F%21%21foo"}
+
+	actual, err := BuildQueryString(foo{Name: "blah+?!!foo", Shape: "Triangl e"})
+	th.AssertNoErr(t, err)
+
+	th.AssertDeepEquals(t, expected, actual)
+}
+
+func TestBuildRequestBody(t *testing.T) {
+	type PasswordCredentials struct {
+		Username string `json:"username" required:"true"`
+		Password string `json:"password" required:"true"`
+	}
+
+	type TokenCredentials struct {
+		ID string `json:"id,omitempty" required:"true"`
+	}
+
+	type orFields struct {
+		Filler int `json:"filler,omitempty"`
+		F1     int `json:"f1,omitempty" or:"F2"`
+		F2     int `json:"f2,omitempty" or:"F1"`
+	}
+
+	// AuthOptions wraps a gophercloud AuthOptions in order to adhere to the AuthOptionsBuilder
+	// interface.
+	type AuthOptions struct {
+		PasswordCredentials `json:"passwordCredentials,omitempty" xor:"TokenCredentials"`
+
+		// The TenantID and TenantName fields are optional for the Identity V2 API.
+		// Some providers allow you to specify a TenantName instead of the TenantId.
+		// Some require both. Your provider's authentication policies will determine
+		// how these fields influence authentication.
+		TenantID   string `json:"tenantId,omitempty"`
+		TenantName string `json:"tenantName,omitempty"`
+
+		// TokenCredentials allows users to authenticate (possibly as another user) with an
+		// authentication token ID.
+		TokenCredentials `json:"token,omitempty" xor:"PasswordCredentials"`
+
+		OrFields orFields `json:"or_fields,omitempty"`
+	}
+
+	var successCases = []struct {
+		opts     AuthOptions
+		expected map[string]interface{}
+	}{
+		{
+			AuthOptions{
+				PasswordCredentials: PasswordCredentials{
+					Username: "me",
+					Password: "swordfish",
+				},
+			},
+			map[string]interface{}{
+				"auth": map[string]interface{}{
+					"passwordCredentials": map[string]interface{}{
+						"password": "swordfish",
+						"username": "me",
+					},
+				},
+			},
+		},
+		{
+			AuthOptions{
+				TokenCredentials: TokenCredentials{
+					ID: "1234567",
+				},
+			},
+			map[string]interface{}{
+				"auth": map[string]interface{}{
+					"token": map[string]interface{}{
+						"id": "1234567",
+					},
+				},
+			},
+		},
+	}
+
+	for _, successCase := range successCases {
+		actual, err := BuildRequestBody(successCase.opts, "auth")
+		th.AssertNoErr(t, err)
+		th.AssertDeepEquals(t, successCase.expected, actual)
+	}
+
+	var failCases = []struct {
+		opts     AuthOptions
+		expected error
+	}{
+		{
+			AuthOptions{
+				TenantID:   "987654321",
+				TenantName: "me",
+			},
+			ErrMissingInput{},
+		},
+		{
+			AuthOptions{
+				TokenCredentials: TokenCredentials{
+					ID: "1234567",
+				},
+				PasswordCredentials: PasswordCredentials{
+					Username: "me",
+					Password: "swordfish",
+				},
+			},
+			ErrMissingInput{},
+		},
+		{
+			AuthOptions{
+				PasswordCredentials: PasswordCredentials{
+					Password: "swordfish",
+				},
+			},
+			ErrMissingInput{},
+		},
+		{
+			AuthOptions{
+				PasswordCredentials: PasswordCredentials{
+					Username: "me",
+					Password: "swordfish",
+				},
+				OrFields: orFields{
+					Filler: 2,
+				},
+			},
+			ErrMissingInput{},
+		},
+	}
+
+	for _, failCase := range failCases {
+		_, err := BuildRequestBody(failCase.opts, "auth")
+		th.AssertDeepEquals(t, reflect.TypeOf(failCase.expected), reflect.TypeOf(err))
+	}
+}
diff --git a/provider_client.go b/provider_client.go
new file mode 100644
index 0000000..f886823
--- /dev/null
+++ b/provider_client.go
@@ -0,0 +1,307 @@
+package gophercloud
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"strings"
+)
+
+// DefaultUserAgent is the default User-Agent string set in the request header.
+const DefaultUserAgent = "gophercloud/2.0.0"
+
+// UserAgent represents a User-Agent header.
+type UserAgent struct {
+	// prepend is the slice of User-Agent strings to prepend to DefaultUserAgent.
+	// All the strings to prepend are accumulated and prepended in the Join method.
+	prepend []string
+}
+
+// Prepend prepends a user-defined string to the default User-Agent string. Users
+// may pass in one or more strings to prepend.
+func (ua *UserAgent) Prepend(s ...string) {
+	ua.prepend = append(s, ua.prepend...)
+}
+
+// Join concatenates all the user-defined User-Agend strings with the default
+// Gophercloud User-Agent string.
+func (ua *UserAgent) Join() string {
+	uaSlice := append(ua.prepend, DefaultUserAgent)
+	return strings.Join(uaSlice, " ")
+}
+
+// ProviderClient stores details that are required to interact with any
+// services within a specific provider's API.
+//
+// Generally, you acquire a ProviderClient by calling the NewClient method in
+// the appropriate provider's child package, providing whatever authentication
+// credentials are required.
+type ProviderClient struct {
+	// IdentityBase is the base URL used for a particular provider's identity
+	// service - it will be used when issuing authenticatation requests. It
+	// should point to the root resource of the identity service, not a specific
+	// identity version.
+	IdentityBase string
+
+	// IdentityEndpoint is the identity endpoint. This may be a specific version
+	// of the identity service. If this is the case, this endpoint is used rather
+	// than querying versions first.
+	IdentityEndpoint string
+
+	// TokenID is the ID of the most recently issued valid token.
+	TokenID string
+
+	// EndpointLocator describes how this provider discovers the endpoints for
+	// its constituent services.
+	EndpointLocator EndpointLocator
+
+	// HTTPClient allows users to interject arbitrary http, https, or other transit behaviors.
+	HTTPClient http.Client
+
+	// UserAgent represents the User-Agent header in the HTTP request.
+	UserAgent UserAgent
+
+	// ReauthFunc is the function used to re-authenticate the user if the request
+	// fails with a 401 HTTP response code. This a needed because there may be multiple
+	// authentication functions for different Identity service versions.
+	ReauthFunc func() error
+
+	Debug bool
+}
+
+// AuthenticatedHeaders returns a map of HTTP headers that are common for all
+// authenticated service requests.
+func (client *ProviderClient) AuthenticatedHeaders() map[string]string {
+	if client.TokenID == "" {
+		return map[string]string{}
+	}
+	return map[string]string{"X-Auth-Token": client.TokenID}
+}
+
+// RequestOpts customizes the behavior of the provider.Request() method.
+type RequestOpts struct {
+	// JSONBody, if provided, will be encoded as JSON and used as the body of the HTTP request. The
+	// content type of the request will default to "application/json" unless overridden by MoreHeaders.
+	// It's an error to specify both a JSONBody and a RawBody.
+	JSONBody interface{}
+	// RawBody contains an io.Reader that will be consumed by the request directly. No content-type
+	// will be set unless one is provided explicitly by MoreHeaders.
+	RawBody io.Reader
+	// JSONResponse, if provided, will be populated with the contents of the response body parsed as
+	// JSON.
+	JSONResponse interface{}
+	// OkCodes contains a list of numeric HTTP status codes that should be interpreted as success. If
+	// the response has a different code, an error will be returned.
+	OkCodes []int
+	// MoreHeaders specifies additional HTTP headers to be provide on the request. If a header is
+	// provided with a blank value (""), that header will be *omitted* instead: use this to suppress
+	// the default Accept header or an inferred Content-Type, for example.
+	MoreHeaders map[string]string
+	// ErrorContext specifies the resource error type to return if an error is encountered.
+	// This lets resources override default error messages based on the response status code.
+	ErrorContext error
+}
+
+var applicationJSON = "application/json"
+
+// Request performs an HTTP request using the ProviderClient's current HTTPClient. An authentication
+// header will automatically be provided.
+func (client *ProviderClient) Request(method, url string, options *RequestOpts) (*http.Response, error) {
+	var body io.Reader
+	var contentType *string
+
+	// Derive the content body by either encoding an arbitrary object as JSON, or by taking a provided
+	// io.ReadSeeker as-is. Default the content-type to application/json.
+	if options.JSONBody != nil {
+		if options.RawBody != nil {
+			panic("Please provide only one of JSONBody or RawBody to gophercloud.Request().")
+		}
+
+		rendered, err := json.Marshal(options.JSONBody)
+		if err != nil {
+			return nil, err
+		}
+
+		body = bytes.NewReader(rendered)
+		contentType = &applicationJSON
+	}
+
+	if options.RawBody != nil {
+		body = options.RawBody
+	}
+
+	// Construct the http.Request.
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	// Populate the request headers. Apply options.MoreHeaders last, to give the caller the chance to
+	// modify or omit any header.
+	if contentType != nil {
+		req.Header.Set("Content-Type", *contentType)
+	}
+	req.Header.Set("Accept", applicationJSON)
+
+	for k, v := range client.AuthenticatedHeaders() {
+		req.Header.Add(k, v)
+	}
+
+	// Set the User-Agent header
+	req.Header.Set("User-Agent", client.UserAgent.Join())
+
+	if options.MoreHeaders != nil {
+		for k, v := range options.MoreHeaders {
+			if v != "" {
+				req.Header.Set(k, v)
+			} else {
+				req.Header.Del(k)
+			}
+		}
+	}
+
+	// Set connection parameter to close the connection immediately when we've got the response
+	req.Close = true
+
+	// Issue the request.
+	resp, err := client.HTTPClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	// Allow default OkCodes if none explicitly set
+	if options.OkCodes == nil {
+		options.OkCodes = defaultOkCodes(method)
+	}
+
+	// Validate the HTTP response status.
+	var ok bool
+	for _, code := range options.OkCodes {
+		if resp.StatusCode == code {
+			ok = true
+			break
+		}
+	}
+
+	if !ok {
+		body, _ := ioutil.ReadAll(resp.Body)
+		resp.Body.Close()
+		//pc := make([]uintptr, 1)
+		//runtime.Callers(2, pc)
+		//f := runtime.FuncForPC(pc[0])
+		respErr := ErrUnexpectedResponseCode{
+			URL:      url,
+			Method:   method,
+			Expected: options.OkCodes,
+			Actual:   resp.StatusCode,
+			Body:     body,
+		}
+		//respErr.Function = "gophercloud.ProviderClient.Request"
+
+		errType := options.ErrorContext
+		switch resp.StatusCode {
+		case http.StatusBadRequest:
+			err = ErrDefault400{respErr}
+			if error400er, ok := errType.(Err400er); ok {
+				err = error400er.Error400(respErr)
+			}
+		case http.StatusUnauthorized:
+			if client.ReauthFunc != nil {
+				err = client.ReauthFunc()
+				if err != nil {
+					e := &ErrUnableToReauthenticate{}
+					e.ErrOriginal = respErr
+					return nil, e
+				}
+				if options.RawBody != nil {
+					if seeker, ok := options.RawBody.(io.Seeker); ok {
+						seeker.Seek(0, 0)
+					}
+				}
+				resp, err = client.Request(method, url, options)
+				if err != nil {
+					switch err.(type) {
+					case *ErrUnexpectedResponseCode:
+						e := &ErrErrorAfterReauthentication{}
+						e.ErrOriginal = err.(*ErrUnexpectedResponseCode)
+						return nil, e
+					default:
+						e := &ErrErrorAfterReauthentication{}
+						e.ErrOriginal = err
+						return nil, e
+					}
+				}
+				return resp, nil
+			}
+			err = ErrDefault401{respErr}
+			if error401er, ok := errType.(Err401er); ok {
+				err = error401er.Error401(respErr)
+			}
+		case http.StatusNotFound:
+			err = ErrDefault404{respErr}
+			if error404er, ok := errType.(Err404er); ok {
+				err = error404er.Error404(respErr)
+			}
+		case http.StatusMethodNotAllowed:
+			err = ErrDefault405{respErr}
+			if error405er, ok := errType.(Err405er); ok {
+				err = error405er.Error405(respErr)
+			}
+		case http.StatusRequestTimeout:
+			err = ErrDefault408{respErr}
+			if error408er, ok := errType.(Err408er); ok {
+				err = error408er.Error408(respErr)
+			}
+		case 429:
+			err = ErrDefault429{respErr}
+			if error429er, ok := errType.(Err429er); ok {
+				err = error429er.Error429(respErr)
+			}
+		case http.StatusInternalServerError:
+			err = ErrDefault500{respErr}
+			if error500er, ok := errType.(Err500er); ok {
+				err = error500er.Error500(respErr)
+			}
+		case http.StatusServiceUnavailable:
+			err = ErrDefault503{respErr}
+			if error503er, ok := errType.(Err503er); ok {
+				err = error503er.Error503(respErr)
+			}
+		}
+
+		if err == nil {
+			err = respErr
+		}
+
+		return resp, err
+	}
+
+	// Parse the response body as JSON, if requested to do so.
+	if options.JSONResponse != nil {
+		defer resp.Body.Close()
+		if err := json.NewDecoder(resp.Body).Decode(options.JSONResponse); err != nil {
+			return nil, err
+		}
+	}
+
+	return resp, nil
+}
+
+func defaultOkCodes(method string) []int {
+	switch {
+	case method == "GET":
+		return []int{200}
+	case method == "POST":
+		return []int{201, 202}
+	case method == "PUT":
+		return []int{201, 202}
+	case method == "PATCH":
+		return []int{200, 204}
+	case method == "DELETE":
+		return []int{202, 204}
+	}
+
+	return []int{}
+}
diff --git a/provider_client_test.go b/provider_client_test.go
new file mode 100644
index 0000000..468b2c3
--- /dev/null
+++ b/provider_client_test.go
@@ -0,0 +1,35 @@
+package gophercloud
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestAuthenticatedHeaders(t *testing.T) {
+	p := &ProviderClient{
+		TokenID: "1234",
+	}
+	expected := map[string]string{"X-Auth-Token": "1234"}
+	actual := p.AuthenticatedHeaders()
+	th.CheckDeepEquals(t, expected, actual)
+}
+
+func TestUserAgent(t *testing.T) {
+	p := &ProviderClient{}
+
+	p.UserAgent.Prepend("custom-user-agent/2.4.0")
+	expected := "custom-user-agent/2.4.0 gophercloud/2.0.0"
+	actual := p.UserAgent.Join()
+	th.CheckEquals(t, expected, actual)
+
+	p.UserAgent.Prepend("another-custom-user-agent/0.3.0", "a-third-ua/5.9.0")
+	expected = "another-custom-user-agent/0.3.0 a-third-ua/5.9.0 custom-user-agent/2.4.0 gophercloud/2.0.0"
+	actual = p.UserAgent.Join()
+	th.CheckEquals(t, expected, actual)
+
+	p.UserAgent = UserAgent{}
+	expected = "gophercloud/2.0.0"
+	actual = p.UserAgent.Join()
+	th.CheckEquals(t, expected, actual)
+}
diff --git a/results.go b/results.go
new file mode 100644
index 0000000..2b7e01f
--- /dev/null
+++ b/results.go
@@ -0,0 +1,262 @@
+package gophercloud
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+)
+
+/*
+Result is an internal type to be used by individual resource packages, but its
+methods will be available on a wide variety of user-facing embedding types.
+
+It acts as a base struct that other Result types, returned from request
+functions, can embed for convenience. All Results capture basic information
+from the HTTP transaction that was performed, including the response body,
+HTTP headers, and any errors that happened.
+
+Generally, each Result type will have an Extract method that can be used to
+further interpret the result's payload in a specific context. Extensions or
+providers can then provide additional extraction functions to pull out
+provider- or extension-specific information as well.
+*/
+type Result struct {
+	// Body is the payload of the HTTP response from the server. In most cases,
+	// this will be the deserialized JSON structure.
+	Body interface{}
+
+	// Header contains the HTTP header structure from the original response.
+	Header http.Header
+
+	// Err is an error that occurred during the operation. It's deferred until
+	// extraction to make it easier to chain the Extract call.
+	Err error
+}
+
+// ExtractInto allows users to provide an object into which `Extract` will extract
+// the `Result.Body`. This would be useful for OpenStack providers that have
+// different fields in the response object than OpenStack proper.
+func (r Result) ExtractInto(to interface{}) error {
+	if r.Err != nil {
+		return r.Err
+	}
+
+	if reader, ok := r.Body.(io.Reader); ok {
+		if readCloser, ok := reader.(io.Closer); ok {
+			defer readCloser.Close()
+		}
+		return json.NewDecoder(reader).Decode(to)
+	}
+
+	b, err := json.Marshal(r.Body)
+	if err != nil {
+		return err
+	}
+	err = json.Unmarshal(b, to)
+
+	return err
+}
+
+// PrettyPrintJSON creates a string containing the full response body as
+// pretty-printed JSON. It's useful for capturing test fixtures and for
+// debugging extraction bugs. If you include its output in an issue related to
+// a buggy extraction function, we will all love you forever.
+func (r Result) PrettyPrintJSON() string {
+	pretty, err := json.MarshalIndent(r.Body, "", "  ")
+	if err != nil {
+		panic(err.Error())
+	}
+	return string(pretty)
+}
+
+// ErrResult is an internal type to be used by individual resource packages, but
+// its methods will be available on a wide variety of user-facing embedding
+// types.
+//
+// It represents results that only contain a potential error and
+// nothing else. Usually, if the operation executed successfully, the Err field
+// will be nil; otherwise it will be stocked with a relevant error. Use the
+// ExtractErr method
+// to cleanly pull it out.
+type ErrResult struct {
+	Result
+}
+
+// ExtractErr is a function that extracts error information, or nil, from a result.
+func (r ErrResult) ExtractErr() error {
+	return r.Err
+}
+
+/*
+HeaderResult is an internal type to be used by individual resource packages, but
+its methods will be available on a wide variety of user-facing embedding types.
+
+It represents a result that only contains an error (possibly nil) and an
+http.Header. This is used, for example, by the objectstorage packages in
+openstack, because most of the operations don't return response bodies, but do
+have relevant information in headers.
+*/
+type HeaderResult struct {
+	Result
+}
+
+// ExtractHeader will return the http.Header and error from the HeaderResult.
+//
+//   header, err := objects.Create(client, "my_container", objects.CreateOpts{}).ExtractHeader()
+func (r HeaderResult) ExtractInto(to interface{}) error {
+	if r.Err != nil {
+		return r.Err
+	}
+
+	tmpHeaderMap := map[string]string{}
+	for k, v := range r.Header {
+		if len(v) > 0 {
+			tmpHeaderMap[k] = v[0]
+		}
+	}
+
+	b, err := json.Marshal(tmpHeaderMap)
+	if err != nil {
+		return err
+	}
+	err = json.Unmarshal(b, to)
+
+	return err
+}
+
+// RFC3339Milli describes a common time format used by some API responses.
+const RFC3339Milli = "2006-01-02T15:04:05.999999Z"
+
+type JSONRFC3339Milli time.Time
+
+func (jt *JSONRFC3339Milli) UnmarshalJSON(data []byte) error {
+	b := bytes.NewBuffer(data)
+	dec := json.NewDecoder(b)
+	var s string
+	if err := dec.Decode(&s); err != nil {
+		return err
+	}
+	t, err := time.Parse(RFC3339Milli, s)
+	if err != nil {
+		return err
+	}
+	*jt = JSONRFC3339Milli(t)
+	return nil
+}
+
+const RFC3339MilliNoZ = "2006-01-02T03:04:05.999999"
+
+type JSONRFC3339MilliNoZ time.Time
+
+func (jt *JSONRFC3339MilliNoZ) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	if s == "" {
+		return nil
+	}
+	t, err := time.Parse(RFC3339MilliNoZ, s)
+	if err != nil {
+		return err
+	}
+	*jt = JSONRFC3339MilliNoZ(t)
+	return nil
+}
+
+type JSONRFC1123 time.Time
+
+func (jt *JSONRFC1123) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	if s == "" {
+		return nil
+	}
+	t, err := time.Parse(time.RFC1123, s)
+	if err != nil {
+		return err
+	}
+	*jt = JSONRFC1123(t)
+	return nil
+}
+
+type JSONUnix time.Time
+
+func (jt *JSONUnix) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	if s == "" {
+		return nil
+	}
+	unix, err := strconv.ParseInt(s, 10, 64)
+	if err != nil {
+		return err
+	}
+	t = time.Unix(unix, 0)
+	*jt = JSONUnix(t)
+	return nil
+}
+
+// RFC3339NoZ is the time format used in Heat (Orchestration).
+const RFC3339NoZ = "2006-01-02T15:04:05"
+
+type JSONRFC3339NoZ time.Time
+
+func (jt *JSONRFC3339NoZ) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	if s == "" {
+		return nil
+	}
+	t, err := time.Parse(RFC3339NoZ, s)
+	if err != nil {
+		return err
+	}
+	*jt = JSONRFC3339NoZ(t)
+	return nil
+}
+
+/*
+Link is an internal type to be used in packages of collection resources that are
+paginated in a certain way.
+
+It's a response substructure common to many paginated collection results that is
+used to point to related pages. Usually, the one we care about is the one with
+Rel field set to "next".
+*/
+type Link struct {
+	Href string `json:"href"`
+	Rel  string `json:"rel"`
+}
+
+/*
+ExtractNextURL is an internal function useful for packages of collection
+resources that are paginated in a certain way.
+
+It attempts to extract the "next" URL from slice of Link structs, or
+"" if no such URL is present.
+*/
+func ExtractNextURL(links []Link) (string, error) {
+	var url string
+
+	for _, l := range links {
+		if l.Rel == "next" {
+			url = l.Href
+		}
+	}
+
+	if url == "" {
+		return "", nil
+	}
+
+	return url, nil
+}
diff --git a/script/acceptancetest b/script/acceptancetest
new file mode 100755
index 0000000..f9c89f4
--- /dev/null
+++ b/script/acceptancetest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the acceptance tests.
+
+exec go test -p=1 -tags 'acceptance fixtures' github.com/rackspace/gophercloud/acceptance/... $@
diff --git a/script/bootstrap b/script/bootstrap
new file mode 100755
index 0000000..6bae6e8
--- /dev/null
+++ b/script/bootstrap
@@ -0,0 +1,26 @@
+#!/bin/bash
+#
+# This script helps new contributors set up their local workstation for
+# gophercloud development and contributions.
+
+# Create the environment
+export GOPATH=$HOME/go/gophercloud
+mkdir -p $GOPATH
+
+# Download gophercloud into that environment
+go get github.com/rackspace/gophercloud
+cd $GOPATH/src/github.com/rackspace/gophercloud
+git checkout master
+
+# Write out the env.sh convenience file.
+cd $GOPATH
+cat <<EOF >env.sh
+#!/bin/bash
+export GOPATH=$(pwd)
+export GOPHERCLOUD=$GOPATH/src/github.com/rackspace/gophercloud
+EOF
+chmod a+x env.sh
+
+# Make changes immediately available as a convenience.
+. ./env.sh
+
diff --git a/script/cibuild b/script/cibuild
new file mode 100755
index 0000000..1cb389e
--- /dev/null
+++ b/script/cibuild
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Test script to be invoked by Travis.
+
+exec script/unittest -v
diff --git a/script/test b/script/test
new file mode 100755
index 0000000..1e03dff
--- /dev/null
+++ b/script/test
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run all the tests.
+
+exec go test -tags 'acceptance fixtures' ./... $@
diff --git a/script/unittest b/script/unittest
new file mode 100755
index 0000000..d3440a9
--- /dev/null
+++ b/script/unittest
@@ -0,0 +1,5 @@
+#!/bin/bash
+#
+# Run the unit tests.
+
+exec go test -tags fixtures ./... $@
diff --git a/service_client.go b/service_client.go
new file mode 100644
index 0000000..7484c67
--- /dev/null
+++ b/service_client.go
@@ -0,0 +1,141 @@
+package gophercloud
+
+import (
+	"io"
+	"net/http"
+	"strings"
+)
+
+// ServiceClient stores details required to interact with a specific service API implemented by a provider.
+// Generally, you'll acquire these by calling the appropriate `New` method on a ProviderClient.
+type ServiceClient struct {
+	// ProviderClient is a reference to the provider that implements this service.
+	*ProviderClient
+
+	// Endpoint is the base URL of the service's API, acquired from a service catalog.
+	// It MUST end with a /.
+	Endpoint string
+
+	// ResourceBase is the base URL shared by the resources within a service's API. It should include
+	// the API version and, like Endpoint, MUST end with a / if set. If not set, the Endpoint is used
+	// as-is, instead.
+	ResourceBase string
+
+	Microversion string
+}
+
+// ResourceBaseURL returns the base URL of any resources used by this service. It MUST end with a /.
+func (client *ServiceClient) ResourceBaseURL() string {
+	if client.ResourceBase != "" {
+		return client.ResourceBase
+	}
+	return client.Endpoint
+}
+
+// ServiceURL constructs a URL for a resource belonging to this provider.
+func (client *ServiceClient) ServiceURL(parts ...string) string {
+	return client.ResourceBaseURL() + strings.Join(parts, "/")
+}
+
+// Get calls `Request` with the "GET" HTTP verb.
+func (client *ServiceClient) Get(url string, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+	if opts == nil {
+		opts = &RequestOpts{}
+	}
+	if JSONResponse != nil {
+		opts.JSONResponse = JSONResponse
+	}
+
+	if opts.MoreHeaders == nil {
+		opts.MoreHeaders = make(map[string]string)
+	}
+	opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+	return client.Request("GET", url, opts)
+}
+
+// Post calls `Request` with the "POST" HTTP verb.
+func (client *ServiceClient) Post(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+	if opts == nil {
+		opts = &RequestOpts{}
+	}
+
+	if v, ok := (JSONBody).(io.Reader); ok {
+		opts.RawBody = v
+	} else if JSONBody != nil {
+		opts.JSONBody = JSONBody
+	}
+
+	if JSONResponse != nil {
+		opts.JSONResponse = JSONResponse
+	}
+
+	if opts.MoreHeaders == nil {
+		opts.MoreHeaders = make(map[string]string)
+	}
+	opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+	return client.Request("POST", url, opts)
+}
+
+// Put calls `Request` with the "PUT" HTTP verb.
+func (client *ServiceClient) Put(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+	if opts == nil {
+		opts = &RequestOpts{}
+	}
+
+	if v, ok := (JSONBody).(io.Reader); ok {
+		opts.RawBody = v
+	} else if JSONBody != nil {
+		opts.JSONBody = JSONBody
+	}
+
+	if JSONResponse != nil {
+		opts.JSONResponse = JSONResponse
+	}
+
+	if opts.MoreHeaders == nil {
+		opts.MoreHeaders = make(map[string]string)
+	}
+	opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+	return client.Request("PUT", url, opts)
+}
+
+// Patch calls `Request` with the "PATCH" HTTP verb.
+func (client *ServiceClient) Patch(url string, JSONBody interface{}, JSONResponse interface{}, opts *RequestOpts) (*http.Response, error) {
+	if opts == nil {
+		opts = &RequestOpts{}
+	}
+
+	if v, ok := (JSONBody).(io.Reader); ok {
+		opts.RawBody = v
+	} else if JSONBody != nil {
+		opts.JSONBody = JSONBody
+	}
+
+	if JSONResponse != nil {
+		opts.JSONResponse = JSONResponse
+	}
+
+	if opts.MoreHeaders == nil {
+		opts.MoreHeaders = make(map[string]string)
+	}
+	opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+	return client.Request("PATCH", url, opts)
+}
+
+// Delete calls `Request` with the "DELETE" HTTP verb.
+func (client *ServiceClient) Delete(url string, opts *RequestOpts) (*http.Response, error) {
+	if opts == nil {
+		opts = &RequestOpts{}
+	}
+
+	if opts.MoreHeaders == nil {
+		opts.MoreHeaders = make(map[string]string)
+	}
+	opts.MoreHeaders["X-OpenStack-Nova-API-Version"] = client.Microversion
+
+	return client.Request("DELETE", url, opts)
+}
diff --git a/service_client_test.go b/service_client_test.go
new file mode 100644
index 0000000..0bd0006
--- /dev/null
+++ b/service_client_test.go
@@ -0,0 +1,14 @@
+package gophercloud
+
+import (
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestServiceURL(t *testing.T) {
+	c := &ServiceClient{Endpoint: "http://123.45.67.8/"}
+	expected := "http://123.45.67.8/more/parts/here"
+	actual := c.ServiceURL("more", "parts", "here")
+	th.CheckEquals(t, expected, actual)
+}
diff --git a/testhelper/client/fake.go b/testhelper/client/fake.go
new file mode 100644
index 0000000..3d81cc9
--- /dev/null
+++ b/testhelper/client/fake.go
@@ -0,0 +1,17 @@
+package client
+
+import (
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/testhelper"
+)
+
+// Fake token to use.
+const TokenID = "cbc36478b0bd8e67e89469c7749d4127"
+
+// ServiceClient returns a generic service client for use in tests.
+func ServiceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		ProviderClient: &gophercloud.ProviderClient{TokenID: TokenID},
+		Endpoint:       testhelper.Endpoint(),
+	}
+}
diff --git a/testhelper/convenience.go b/testhelper/convenience.go
new file mode 100644
index 0000000..cf33e1a
--- /dev/null
+++ b/testhelper/convenience.go
@@ -0,0 +1,329 @@
+package testhelper
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"strings"
+	"testing"
+)
+
+const (
+	logBodyFmt = "\033[1;31m%s %s\033[0m"
+	greenCode  = "\033[0m\033[1;32m"
+	yellowCode = "\033[0m\033[1;33m"
+	resetCode  = "\033[0m\033[1;31m"
+)
+
+func prefix(depth int) string {
+	_, file, line, _ := runtime.Caller(depth)
+	return fmt.Sprintf("Failure in %s, line %d:", filepath.Base(file), line)
+}
+
+func green(str interface{}) string {
+	return fmt.Sprintf("%s%#v%s", greenCode, str, resetCode)
+}
+
+func yellow(str interface{}) string {
+	return fmt.Sprintf("%s%#v%s", yellowCode, str, resetCode)
+}
+
+func logFatal(t *testing.T, str string) {
+	t.Fatalf(logBodyFmt, prefix(3), str)
+}
+
+func logError(t *testing.T, str string) {
+	t.Errorf(logBodyFmt, prefix(3), str)
+}
+
+type diffLogger func([]string, interface{}, interface{})
+
+type visit struct {
+	a1  uintptr
+	a2  uintptr
+	typ reflect.Type
+}
+
+// Recursively visits the structures of "expected" and "actual". The diffLogger function will be
+// invoked with each different value encountered, including the reference path that was followed
+// to get there.
+func deepDiffEqual(expected, actual reflect.Value, visited map[visit]bool, path []string, logDifference diffLogger) {
+	defer func() {
+		// Fall back to the regular reflect.DeepEquals function.
+		if r := recover(); r != nil {
+			var e, a interface{}
+			if expected.IsValid() {
+				e = expected.Interface()
+			}
+			if actual.IsValid() {
+				a = actual.Interface()
+			}
+
+			if !reflect.DeepEqual(e, a) {
+				logDifference(path, e, a)
+			}
+		}
+	}()
+
+	if !expected.IsValid() && actual.IsValid() {
+		logDifference(path, nil, actual.Interface())
+		return
+	}
+	if expected.IsValid() && !actual.IsValid() {
+		logDifference(path, expected.Interface(), nil)
+		return
+	}
+	if !expected.IsValid() && !actual.IsValid() {
+		return
+	}
+
+	hard := func(k reflect.Kind) bool {
+		switch k {
+		case reflect.Array, reflect.Map, reflect.Slice, reflect.Struct:
+			return true
+		}
+		return false
+	}
+
+	if expected.CanAddr() && actual.CanAddr() && hard(expected.Kind()) {
+		addr1 := expected.UnsafeAddr()
+		addr2 := actual.UnsafeAddr()
+
+		if addr1 > addr2 {
+			addr1, addr2 = addr2, addr1
+		}
+
+		if addr1 == addr2 {
+			// References are identical. We can short-circuit
+			return
+		}
+
+		typ := expected.Type()
+		v := visit{addr1, addr2, typ}
+		if visited[v] {
+			// Already visited.
+			return
+		}
+
+		// Remember this visit for later.
+		visited[v] = true
+	}
+
+	switch expected.Kind() {
+	case reflect.Array:
+		for i := 0; i < expected.Len(); i++ {
+			hop := append(path, fmt.Sprintf("[%d]", i))
+			deepDiffEqual(expected.Index(i), actual.Index(i), visited, hop, logDifference)
+		}
+		return
+	case reflect.Slice:
+		if expected.IsNil() != actual.IsNil() {
+			logDifference(path, expected.Interface(), actual.Interface())
+			return
+		}
+		if expected.Len() == actual.Len() && expected.Pointer() == actual.Pointer() {
+			return
+		}
+		for i := 0; i < expected.Len(); i++ {
+			hop := append(path, fmt.Sprintf("[%d]", i))
+			deepDiffEqual(expected.Index(i), actual.Index(i), visited, hop, logDifference)
+		}
+		return
+	case reflect.Interface:
+		if expected.IsNil() != actual.IsNil() {
+			logDifference(path, expected.Interface(), actual.Interface())
+			return
+		}
+		deepDiffEqual(expected.Elem(), actual.Elem(), visited, path, logDifference)
+		return
+	case reflect.Ptr:
+		deepDiffEqual(expected.Elem(), actual.Elem(), visited, path, logDifference)
+		return
+	case reflect.Struct:
+		for i, n := 0, expected.NumField(); i < n; i++ {
+			field := expected.Type().Field(i)
+			hop := append(path, "."+field.Name)
+			deepDiffEqual(expected.Field(i), actual.Field(i), visited, hop, logDifference)
+		}
+		return
+	case reflect.Map:
+		if expected.IsNil() != actual.IsNil() {
+			logDifference(path, expected.Interface(), actual.Interface())
+			return
+		}
+		if expected.Len() == actual.Len() && expected.Pointer() == actual.Pointer() {
+			return
+		}
+
+		var keys []reflect.Value
+		if expected.Len() >= actual.Len() {
+			keys = expected.MapKeys()
+		} else {
+			keys = actual.MapKeys()
+		}
+
+		for _, k := range keys {
+			expectedValue := expected.MapIndex(k)
+			actualValue := expected.MapIndex(k)
+
+			if !expectedValue.IsValid() {
+				logDifference(path, nil, actual.Interface())
+				return
+			}
+			if !actualValue.IsValid() {
+				logDifference(path, expected.Interface(), nil)
+				return
+			}
+
+			hop := append(path, fmt.Sprintf("[%v]", k))
+			deepDiffEqual(expectedValue, actualValue, visited, hop, logDifference)
+		}
+		return
+	case reflect.Func:
+		if expected.IsNil() != actual.IsNil() {
+			logDifference(path, expected.Interface(), actual.Interface())
+		}
+		return
+	default:
+		if expected.Interface() != actual.Interface() {
+			logDifference(path, expected.Interface(), actual.Interface())
+		}
+	}
+}
+
+func deepDiff(expected, actual interface{}, logDifference diffLogger) {
+	if expected == nil || actual == nil {
+		logDifference([]string{}, expected, actual)
+		return
+	}
+
+	expectedValue := reflect.ValueOf(expected)
+	actualValue := reflect.ValueOf(actual)
+
+	if expectedValue.Type() != actualValue.Type() {
+		logDifference([]string{}, expected, actual)
+		return
+	}
+	deepDiffEqual(expectedValue, actualValue, map[visit]bool{}, []string{}, logDifference)
+}
+
+// AssertEquals compares two arbitrary values and performs a comparison. If the
+// comparison fails, a fatal error is raised that will fail the test
+func AssertEquals(t *testing.T, expected, actual interface{}) {
+	if expected != actual {
+		logFatal(t, fmt.Sprintf("expected %s but got %s", green(expected), yellow(actual)))
+	}
+}
+
+// CheckEquals is similar to AssertEquals, except with a non-fatal error
+func CheckEquals(t *testing.T, expected, actual interface{}) {
+	if expected != actual {
+		logError(t, fmt.Sprintf("expected %s but got %s", green(expected), yellow(actual)))
+	}
+}
+
+// AssertDeepEquals - like Equals - performs a comparison - but on more complex
+// structures that requires deeper inspection
+func AssertDeepEquals(t *testing.T, expected, actual interface{}) {
+	pre := prefix(2)
+
+	differed := false
+	deepDiff(expected, actual, func(path []string, expected, actual interface{}) {
+		differed = true
+		t.Errorf("\033[1;31m%sat %s expected %s, but got %s\033[0m",
+			pre,
+			strings.Join(path, ""),
+			green(expected),
+			yellow(actual))
+	})
+	if differed {
+		logFatal(t, "The structures were different.")
+	}
+}
+
+// CheckDeepEquals is similar to AssertDeepEquals, except with a non-fatal error
+func CheckDeepEquals(t *testing.T, expected, actual interface{}) {
+	pre := prefix(2)
+
+	deepDiff(expected, actual, func(path []string, expected, actual interface{}) {
+		t.Errorf("\033[1;31m%s at %s expected %s, but got %s\033[0m",
+			pre,
+			strings.Join(path, ""),
+			green(expected),
+			yellow(actual))
+	})
+}
+
+// isJSONEquals is a utility function that implements JSON comparison for AssertJSONEquals and
+// CheckJSONEquals.
+func isJSONEquals(t *testing.T, expectedJSON string, actual interface{}) bool {
+	var parsedExpected, parsedActual interface{}
+	err := json.Unmarshal([]byte(expectedJSON), &parsedExpected)
+	if err != nil {
+		t.Errorf("Unable to parse expected value as JSON: %v", err)
+		return false
+	}
+
+	jsonActual, err := json.Marshal(actual)
+	AssertNoErr(t, err)
+	err = json.Unmarshal(jsonActual, &parsedActual)
+	AssertNoErr(t, err)
+
+	if !reflect.DeepEqual(parsedExpected, parsedActual) {
+		prettyExpected, err := json.MarshalIndent(parsedExpected, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expectedJSON)
+		} else {
+			// We can't use green() here because %#v prints prettyExpected as a byte array literal, which
+			// is... unhelpful. Converting it to a string first leaves "\n" uninterpreted for some reason.
+			t.Logf("Expected JSON:\n%s%s%s", greenCode, prettyExpected, resetCode)
+		}
+
+		prettyActual, err := json.MarshalIndent(actual, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print actual JSON: %v\n%#v", err, actual)
+		} else {
+			// We can't use yellow() for the same reason.
+			t.Logf("Actual JSON:\n%s%s%s", yellowCode, prettyActual, resetCode)
+		}
+
+		return false
+	}
+	return true
+}
+
+// AssertJSONEquals serializes a value as JSON, parses an expected string as JSON, and ensures that
+// both are consistent. If they aren't, the expected and actual structures are pretty-printed and
+// shown for comparison.
+//
+// This is useful for comparing structures that are built as nested map[string]interface{} values,
+// which are a pain to construct as literals.
+func AssertJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+	if !isJSONEquals(t, expectedJSON, actual) {
+		logFatal(t, "The generated JSON structure differed.")
+	}
+}
+
+// CheckJSONEquals is similar to AssertJSONEquals, but nonfatal.
+func CheckJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+	if !isJSONEquals(t, expectedJSON, actual) {
+		logError(t, "The generated JSON structure differed.")
+	}
+}
+
+// AssertNoErr is a convenience function for checking whether an error value is
+// an actual error
+func AssertNoErr(t *testing.T, e error) {
+	if e != nil {
+		logFatal(t, fmt.Sprintf("unexpected error %s", yellow(e.Error())))
+	}
+}
+
+// CheckNoErr is similar to AssertNoErr, except with a non-fatal error
+func CheckNoErr(t *testing.T, e error) {
+	if e != nil {
+		logError(t, fmt.Sprintf("unexpected error %s", yellow(e.Error())))
+	}
+}
diff --git a/testhelper/doc.go b/testhelper/doc.go
new file mode 100644
index 0000000..25b4dfe
--- /dev/null
+++ b/testhelper/doc.go
@@ -0,0 +1,4 @@
+/*
+Package testhelper container methods that are useful for writing unit tests.
+*/
+package testhelper
diff --git a/testhelper/fixture/helper.go b/testhelper/fixture/helper.go
new file mode 100644
index 0000000..fe98c86
--- /dev/null
+++ b/testhelper/fixture/helper.go
@@ -0,0 +1,31 @@
+package fixture
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+	"github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func SetupHandler(t *testing.T, url, method, requestBody, responseBody string, status int) {
+	th.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, method)
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		if requestBody != "" {
+			th.TestJSONRequest(t, r, requestBody)
+		}
+
+		if responseBody != "" {
+			w.Header().Add("Content-Type", "application/json")
+		}
+
+		w.WriteHeader(status)
+
+		if responseBody != "" {
+			fmt.Fprintf(w, responseBody)
+		}
+	})
+}
diff --git a/testhelper/http_responses.go b/testhelper/http_responses.go
new file mode 100644
index 0000000..e1f1f9a
--- /dev/null
+++ b/testhelper/http_responses.go
@@ -0,0 +1,91 @@
+package testhelper
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"testing"
+)
+
+var (
+	// Mux is a multiplexer that can be used to register handlers.
+	Mux *http.ServeMux
+
+	// Server is an in-memory HTTP server for testing.
+	Server *httptest.Server
+)
+
+// SetupHTTP prepares the Mux and Server.
+func SetupHTTP() {
+	Mux = http.NewServeMux()
+	Server = httptest.NewServer(Mux)
+}
+
+// TeardownHTTP releases HTTP-related resources.
+func TeardownHTTP() {
+	Server.Close()
+}
+
+// Endpoint returns a fake endpoint that will actually target the Mux.
+func Endpoint() string {
+	return Server.URL + "/"
+}
+
+// TestFormValues ensures that all the URL parameters given to the http.Request are the same as values.
+func TestFormValues(t *testing.T, r *http.Request, values map[string]string) {
+	want := url.Values{}
+	for k, v := range values {
+		want.Add(k, v)
+	}
+
+	r.ParseForm()
+	if !reflect.DeepEqual(want, r.Form) {
+		t.Errorf("Request parameters = %v, want %v", r.Form, want)
+	}
+}
+
+// TestMethod checks that the Request has the expected method (e.g. GET, POST).
+func TestMethod(t *testing.T, r *http.Request, expected string) {
+	if expected != r.Method {
+		t.Errorf("Request method = %v, expected %v", r.Method, expected)
+	}
+}
+
+// TestHeader checks that the header on the http.Request matches the expected value.
+func TestHeader(t *testing.T, r *http.Request, header string, expected string) {
+	if actual := r.Header.Get(header); expected != actual {
+		t.Errorf("Header %s = %s, expected %s", header, actual, expected)
+	}
+}
+
+// TestBody verifies that the request body matches an expected body.
+func TestBody(t *testing.T, r *http.Request, expected string) {
+	b, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		t.Errorf("Unable to read body: %v", err)
+	}
+	str := string(b)
+	if expected != str {
+		t.Errorf("Body = %s, expected %s", str, expected)
+	}
+}
+
+// TestJSONRequest verifies that the JSON payload of a request matches an expected structure, without asserting things about
+// whitespace or ordering.
+func TestJSONRequest(t *testing.T, r *http.Request, expected string) {
+	b, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		t.Errorf("Unable to read request body: %v", err)
+	}
+
+	var actualJSON interface{}
+	err = json.Unmarshal(b, &actualJSON)
+	if err != nil {
+		t.Errorf("Unable to parse request body as JSON: %v", err)
+	}
+
+	CheckJSONEquals(t, expected, actualJSON)
+}
diff --git a/util.go b/util.go
new file mode 100644
index 0000000..3d6a4e4
--- /dev/null
+++ b/util.go
@@ -0,0 +1,82 @@
+package gophercloud
+
+import (
+	"errors"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// WaitFor polls a predicate function, once per second, up to a timeout limit.
+// It usually does this to wait for a resource to transition to a certain state.
+// Resource packages will wrap this in a more convenient function that's
+// specific to a certain resource, but it can also be useful on its own.
+func WaitFor(timeout int, predicate func() (bool, error)) error {
+	start := time.Now().Second()
+	for {
+		// Force a 1s sleep
+		time.Sleep(1 * time.Second)
+
+		// If a timeout is set, and that's been exceeded, shut it down
+		if timeout >= 0 && time.Now().Second()-start >= timeout {
+			return errors.New("A timeout occurred")
+		}
+
+		// Execute the function
+		satisfied, err := predicate()
+		if err != nil {
+			return err
+		}
+		if satisfied {
+			return nil
+		}
+	}
+}
+
+// NormalizeURL is an internal function to be used by provider clients.
+//
+// It ensures that each endpoint URL has a closing `/`, as expected by
+// ServiceClient's methods.
+func NormalizeURL(url string) string {
+	if !strings.HasSuffix(url, "/") {
+		return url + "/"
+	}
+	return url
+}
+
+// NormalizePathURL is used to convert rawPath to a fqdn, using basePath as
+// a reference in the filesystem, if necessary. basePath is assumed to contain
+// either '.' when first used, or the file:// type fqdn of the parent resource.
+// e.g. myFavScript.yaml => file://opt/lib/myFavScript.yaml
+func NormalizePathURL(basePath, rawPath string) (string, error) {
+	u, err := url.Parse(rawPath)
+	if err != nil {
+		return "", err
+	}
+	// if a scheme is defined, it must be a fqdn already
+	if u.Scheme != "" {
+		return u.String(), nil
+	}
+	// if basePath is a url, then child resources are assumed to be relative to it
+	bu, err := url.Parse(basePath)
+	if err != nil {
+		return "", err
+	}
+	var basePathSys, absPathSys string
+	if bu.Scheme != "" {
+		basePathSys = filepath.FromSlash(bu.Path)
+		absPathSys = filepath.Join(basePathSys, rawPath)
+		bu.Path = filepath.ToSlash(absPathSys)
+		return bu.String(), nil
+	}
+
+	absPathSys = filepath.Join(basePath, rawPath)
+	u.Path = filepath.ToSlash(absPathSys)
+	if err != nil {
+		return "", err
+	}
+	u.Scheme = "file"
+	return u.String(), nil
+
+}
diff --git a/util_test.go b/util_test.go
new file mode 100644
index 0000000..6b0d734
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1,85 @@
+package gophercloud
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	th "github.com/gophercloud/gophercloud/testhelper"
+)
+
+func TestWaitFor(t *testing.T) {
+	err := WaitFor(5, func() (bool, error) {
+		return true, nil
+	})
+	th.CheckNoErr(t, err)
+}
+
+func TestNormalizeURL(t *testing.T) {
+	urls := []string{
+		"NoSlashAtEnd",
+		"SlashAtEnd/",
+	}
+	expected := []string{
+		"NoSlashAtEnd/",
+		"SlashAtEnd/",
+	}
+	for i := 0; i < len(expected); i++ {
+		th.CheckEquals(t, expected[i], NormalizeURL(urls[i]))
+	}
+
+}
+
+func TestNormalizePathURL(t *testing.T) {
+	baseDir, _ := os.Getwd()
+
+	rawPath := "template.yaml"
+	basePath, _ := filepath.Abs(".")
+	result, _ := NormalizePathURL(basePath, rawPath)
+	expected := strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "template.yaml"}, "/")
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "http://www.google.com"
+	basePath, _ = filepath.Abs(".")
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = "http://www.google.com"
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml"
+	basePath, _ = filepath.Abs(".")
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "very/nested/file.yaml"}, "/")
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml"
+	basePath = "http://www.google.com"
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = "http://www.google.com/very/nested/file.yaml"
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml/"
+	basePath = "http://www.google.com/"
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = "http://www.google.com/very/nested/file.yaml"
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml"
+	basePath = "http://www.google.com/even/more"
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = "http://www.google.com/even/more/very/nested/file.yaml"
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml"
+	basePath = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more"}, "/")
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more/very/nested/file.yaml"}, "/")
+	th.CheckEquals(t, expected, result)
+
+	rawPath = "very/nested/file.yaml/"
+	basePath = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more"}, "/")
+	result, _ = NormalizePathURL(basePath, rawPath)
+	expected = strings.Join([]string{"file:/", filepath.ToSlash(baseDir), "only/file/even/more/very/nested/file.yaml"}, "/")
+	th.CheckEquals(t, expected, result)
+
+}