diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 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
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/atlassian-connect-descriptor.cabal b/atlassian-connect-descriptor.cabal
new file mode 100644
--- /dev/null
+++ b/atlassian-connect-descriptor.cabal
@@ -0,0 +1,128 @@
+-- Initial atlassian-connect-descriptor.cabal generated by cabal init.  For
+--  further documentation, see http://haskell.org/cabal/users-guide/
+
+-- The name of the package.
+name:                atlassian-connect-descriptor
+
+-- The package version.  See the Haskell package versioning policy (PVP) 
+-- for standards guiding when and how versions should be incremented.
+-- http://www.haskell.org/haskellwiki/Package_versioning_policy
+-- PVP summary:      +-+------- breaking API changes
+--                   | | +----- non-breaking API additions
+--                   | | | +--- code changes with no API change
+version:             0.1.0.0
+
+-- A short (one-line) description of the package.
+synopsis:            Code that helps you create a valid Atlassian Connect Descriptor.
+
+-- A longer description of the package.
+-- description:         
+
+-- The license under which the package is released.
+license:             Apache-2.0
+
+-- The file containing the license text.
+license-file:        LICENSE
+
+-- The package author(s).
+author:              Robert Massaioli
+
+-- An email address to which users can send suggestions, bug reports, and 
+-- patches.
+maintainer:          rmassaioli@atlassian.com
+
+-- A copyright notice.
+-- copyright:           
+
+category:            Data
+
+build-type:          Simple
+
+-- Extra files to be distributed with the package, such as examples or a 
+-- README.
+-- extra-source-files:  
+
+-- Constraint on the version of Cabal needed to build this package.
+cabal-version:       >=1.20
+
+flag network-uri
+   description: Get Network.URI from the network-uri package
+   default: True
+
+library
+  -- Modules exported by the library.
+  exposed-modules:    Data.Connect.Descriptor
+  
+  -- Modules included in this library but not exported.
+  other-modules:      Data.Connect.AesonHelpers
+                      , Data.Connect.BaseTypes
+                      , Data.Connect.Conditions
+                      , Data.Connect.Lifecycle
+                      , Data.Connect.Modules
+                      , Data.Connect.OrphanInstances
+                      , Data.Connect.Scopes
+                      , Data.Connect.Webhooks
+  
+  -- LANGUAGE extensions used by modules in this package.
+  -- other-extensions:    
+  
+  -- Other library packages from which modules are imported.
+  build-depends:       base         >=4.6 && <5
+                       , aeson      >  0.7.0.3 && < 0.8
+                       , text       >= 0.11    && <= 1.2
+                       , time-units == 1.*
+                       , cases      == 0.1.*
+                       , unordered-containers == 0.2.*
+
+  if flag(network-uri)
+     build-depends: network-uri >= 2.6, network >= 2.6
+  else
+     build-depends: network-uri < 2.6, network < 2.6
+  
+  -- Directories containing source files.
+  hs-source-dirs:      src
+  
+  -- Base language which the package is written in.
+  default-language:    Haskell2010
+  ghc-options: -Wall
+
+Test-Suite test-descriptor
+  hs-source-dirs: tests, src
+  type:        detailed-0.9
+
+  test-module: Test
+  other-modules: Util
+                 , AssertionHelpers
+                 , ValueExtractors
+                 , ConditionsTest
+                 , ModulesTest
+                 , DescriptorTest
+                 , Data.Connect.Descriptor
+                 , Data.Connect.AesonHelpers
+                 , Data.Connect.BaseTypes
+                 , Data.Connect.Conditions
+                 , Data.Connect.Lifecycle
+                 , Data.Connect.Modules
+                 , Data.Connect.OrphanInstances
+                 , Data.Connect.Scopes
+                 , Data.Connect.Webhooks
+
+  build-depends:
+    base
+    , Cabal >= 1.20
+    , HUnit
+    , vector
+    , aeson
+    , scientific
+    , text
+    , time-units
+    , cases
+    , unordered-containers
+
+  if flag(network-uri)
+     build-depends: network-uri >= 2.6, network >= 2.6
+  else
+     build-depends: network-uri < 2.6, network < 2.6
+
+  default-language:    Haskell2010
+  ghc-options: -Wall
diff --git a/dist/build/test-descriptorStub/test-descriptorStub-tmp/test-descriptorStub.hs b/dist/build/test-descriptorStub/test-descriptorStub-tmp/test-descriptorStub.hs
new file mode 100644
--- /dev/null
+++ b/dist/build/test-descriptorStub/test-descriptorStub-tmp/test-descriptorStub.hs
@@ -0,0 +1,5 @@
+module Main ( main ) where
+import Distribution.Simple.Test.LibV09 ( stubMain )
+import Test ( tests )
+main :: IO ()
+main = stubMain tests
diff --git a/src/Data/Connect/AesonHelpers.hs b/src/Data/Connect/AesonHelpers.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/AesonHelpers.hs
@@ -0,0 +1,33 @@
+module Data.Connect.AesonHelpers
+   ( baseOptions
+   , stripFieldNamePrefix
+   , dropSuffixAndSnakeCase
+   ) where
+
+import qualified Cases            as CS
+import           Data.Aeson.Types
+import qualified Data.Char        as C
+import qualified Data.List        as L
+import           Data.Maybe       (fromMaybe)
+import qualified Data.Text        as T
+
+baseOptions :: Options
+baseOptions = defaultOptions
+   { omitNothingFields = True
+   }
+
+stripFieldNamePrefix :: String -> String -> String
+stripFieldNamePrefix pre = lowerFirst . try (L.stripPrefix pre)
+
+dropSuffixAndSnakeCase :: String -> String -> String
+dropSuffixAndSnakeCase suffix = T.unpack . CS.process CS.lower CS.snake . T.pack . dropSuffix suffix
+
+lowerFirst :: String -> String
+lowerFirst (x : xs) = C.toLower x : xs
+lowerFirst [] = []
+
+dropSuffix :: String -> String -> String
+dropSuffix suffix = reverse . try (L.stripPrefix . reverse $ suffix) . reverse
+
+try :: (a -> Maybe a) -> a -> a
+try f v = fromMaybe v (f v)
diff --git a/src/Data/Connect/BaseTypes.hs b/src/Data/Connect/BaseTypes.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/BaseTypes.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE FlexibleInstances          #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings          #-}
+module Data.Connect.BaseTypes where
+
+import           Data.Aeson
+import           Data.Aeson.Types
+import           Data.Connect.AesonHelpers
+import           Data.Connect.OrphanInstances ()
+import           Data.Text
+import qualified Data.Time.Units              as DTU
+import           GHC.Generics
+import qualified Network.URI                  as NU
+
+-- | This data type represents a Key for a particular data type.
+data Key t a = Key t deriving (Show, Eq, Generic)
+
+-- | This data type represents an Atlassian Connect Add-on key.
+data PluginKey = PluginKey Text deriving (Show, Eq, Generic)
+
+-- | Represents a timeout in seconds.
+newtype Timeout = Timeout DTU.Second deriving (Show, Eq, Enum, Num, Ord, Real, Integral)
+
+-- | Represents the Vendor of the add-on; which will be you. Put your details in this structure.
+data Vendor = Vendor
+   { vendorName :: Name Vendor -- ^ Your name as a Vendor. Might be your personal name or your business name.
+   , vendorUrl :: NU.URI -- ^ A URL to a website that represents you as a vendor.
+   } deriving (Show, Eq, Generic)
+
+-- | If your Atlassian Connect addon wants to perform any server side communication with the host product then you will
+-- need to use authentication. Otherwise you should specify that you don't need authentication.
+data Authentication = Authentication
+   { authType :: AuthType -- ^ The authentication type that you wish to use.
+   } deriving (Show, Eq, Generic)
+
+-- | The authentication type that you wish to use in your Add-on.
+data AuthType
+   = Jwt -- ^ If you need to communicate with the host product then you will want to request JWT authentication.
+   | None -- ^ If you do not need to communicate the host product then you should request None for authentication.
+   deriving (Show, Eq, Generic)
+
+-- | Represents an arbitrary icon. Potentially for an Atlassian Connect module or for the entire add-on itself.
+data IconDetails = IconDetails
+   { iconUrl    :: Text -- ^ The URI to the icon.
+   , iconWidth  :: Maybe Integer -- ^ The width of the icon.
+   , iconHeight :: Maybe Integer -- ^ The height of the icon.
+   } deriving (Show, Generic)
+
+instance ToJSON PluginKey
+
+-- | Atlassian Connect descriptors contain many names: module names, add-on names, vendor names etc. We want to make sure
+-- that these names don't get put in places that they do not belong. Or, if they do get moved around, they get moved around
+-- specifically. We are just adding type saefty to names.
+data Name a = Name Text deriving (Show, Eq, Generic)
+
+instance ToJSON (Name PluginKey)
+instance ToJSON (Name Vendor)
+
+instance ToJSON IconDetails where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "icon"
+      }
+
+instance ToJSON Vendor where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "vendor"
+      }
+
+instance ToJSON Authentication where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "auth"
+      }
+
+instance ToJSON AuthType where
+   toJSON Jwt  = "jwt"
+   toJSON None  = "none"
diff --git a/src/Data/Connect/Conditions.hs b/src/Data/Connect/Conditions.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Conditions.hs
@@ -0,0 +1,192 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Data.Connect.Conditions
+    ( Condition(..)
+    , ConditionType(..)
+    , ConditionSource(..)
+    , JIRACondition(..)
+    , ConfluenceCondition(..)
+    , staticJiraCondition
+    , staticConfluenceCondition
+    , remoteCondition
+    , invertCondition
+    ) where
+
+import           Data.Aeson
+import           Data.Connect.AesonHelpers
+import           Data.Connect.OrphanInstances ()
+import           GHC.Generics
+import qualified Data.HashMap.Strict as HM
+
+-- | A 'Condition' can be placed on an Atlassian Connect Module to cause it to display or not based on the result it
+-- returns. For example, you can choose not to show a WebPanel if the user viewing the page is not logged in. Conditions
+-- are very useful in curating when your modules will appear to your users.
+--
+-- The Atlassian Connect documentation describes conditions fully:
+-- <https://developer.atlassian.com/static/connect/docs/concepts/conditions.html>
+data Condition
+   -- | A single condition based on a source.
+   = SingleCondition
+   { conditionSource   :: ConditionSource -- ^ The source of this condition.
+   , conditionInverted :: Bool -- ^ If you should invert the condition. For example, only show if user is NOT logged in.
+   , conditionParams :: HM.HashMap String String -- ^ Extra parameters to pass with the condition to give it context.
+   }
+   -- | A condition that is the composition of one or more conditions. The 'ConditionType' decides the way in which the
+   -- conditions are composed
+   | CompositeCondition
+   { subConditions :: [Condition] -- ^ The conditions that will be merged together.
+   , conditionType :: ConditionType -- ^ The way in which the conditions will be merged together.
+   }
+   deriving (Show)
+
+-- | Turn a standard JIRA Condition into a regular 'Condition'.
+staticJiraCondition :: JIRACondition -> Condition
+staticJiraCondition c = SingleCondition { conditionSource = StaticJIRACondition c, conditionInverted = False, conditionParams = HM.empty }
+
+-- | Turn a standard Confluence Condition into a regular 'Condition'.
+staticConfluenceCondition :: ConfluenceCondition -> Condition
+staticConfluenceCondition c = SingleCondition { conditionSource = StaticConfluenceCondition c, conditionInverted = False, conditionParams = HM.empty }
+
+-- | Given a URI that defines a remote condition convert it into a regular 'Condition'.
+remoteCondition :: String -> Condition
+remoteCondition conditionLocation = SingleCondition { conditionSource = RemoteCondition conditionLocation, conditionInverted = False, conditionParams = HM.empty }
+
+-- | Invert the given condition.
+invertCondition :: Condition -> Condition
+invertCondition c@(SingleCondition {}) = c { conditionInverted = not . conditionInverted $ c }
+-- One application of DeMorgans law for composite conditions.
+invertCondition (CompositeCondition cs ct) = CompositeCondition (fmap invertCondition cs) (invertConditionType ct)
+
+invertConditionType :: ConditionType -> ConditionType
+invertConditionType AndCondition = OrCondition
+invertConditionType OrCondition = AndCondition
+
+instance ToJSON Condition where
+   toJSON sc@(SingleCondition {}) = object
+      [ "condition" .= conditionSource sc
+      , "invert" .= conditionInverted sc
+      , "params" .= conditionParams sc
+      ]
+   toJSON cc@(CompositeCondition {}) = object [ "conditions" .= subConditions cc, "type" .= conditionType cc]
+
+-- | Composite Conditions can be joined together to behave as a single condition. The way that you can join them together
+-- is decided by the condition type.
+data ConditionType
+   = AndCondition -- ^ The boolean intersection of the conditions.
+   | OrCondition -- ^ The boolean union of the conditions.
+   deriving (Eq, Show)
+
+instance ToJSON ConditionType where
+   toJSON AndCondition = String "AND"
+   toJSON OrCondition  = String "OR"
+
+-- | Conditions can be specified by the Host application or by the Atlassian Connect add-on itself. This means that the
+-- source of the condition needs to be specified and that is what you can use this data type to do.
+data ConditionSource
+   -- | A static JIRA condition.
+   = StaticJIRACondition        JIRACondition
+   -- | A static Confluence condition.
+   | StaticConfluenceCondition  ConfluenceCondition
+   -- | A remote condition defined by your Atlassian Connect application.
+   | RemoteCondition
+      { remoteConditionPath :: String -- ^ The relative URI that you should hit in your Atlassian Connect application to
+                                      -- get the condition result. This URI, when hit, should return a JSON response in
+                                      -- the format:
+                                      --
+                                      -- > { "shouldDisplay": <true|false> }
+      }
+   deriving (Show, Eq)
+
+instance ToJSON ConditionSource where
+   toJSON (StaticJIRACondition x) = toJSON x
+   toJSON (StaticConfluenceCondition x) = toJSON x
+   toJSON (RemoteCondition x) = toJSON x
+
+-- The JIRA Conditions have been taken from:
+-- https://developer.atlassian.com/static/connect/docs/modules/fragment/single-condition.html
+-- as of the following date: Tue 23 Sep 2014 08:45:49 EST
+-- Please update the date above whenever you update these conditions.
+
+-- | The conditions that have been provided by JIRA. Please see the single condition documentation for more details:
+-- <https://developer.atlassian.com/static/connect/docs/modules/fragment/single-condition.html>
+data JIRACondition
+   = CanAttachFileToIssueJiraCondition
+   | CanManageAttachmentsJiraCondition
+   | FeatureFlagJiraCondition
+   | HasIssuePermissionJiraCondition
+   | HasProjectPermissionJiraCondition
+   | HasSelectedProjectPermissionJiraCondition
+   | HasSubTasksAvaliableJiraCondition
+   | HasVotedForIssueJiraCondition
+   | IsAdminModeJiraCondition
+   | IsIssueAssignedToCurrentUserJiraCondition
+   | IsIssueEditableJiraCondition
+   | IsIssueReportedByCurrentUserJiraCondition
+   | IsIssueUnresolvedJiraCondition
+   | IsSubTaskJiraCondition
+   | IsWatchingIssueJiraCondition
+   | LinkingEnabledJiraCondition
+   | SubTasksEnabledJiraCondition
+   | TimeTrackingEnabledJiraCondition
+   | UserHasIssueHistoryJiraCondition
+   | UserIsAdminJiraCondition
+   | UserIsLoggedInJiraCondition
+   | UserIsProjectAdminJiraCondition
+   | UserIsSysadminJiraCondition
+   | UserIsTheLoggedInUserJiraCondition
+   | VotingEnabledJiraCondition
+   | WatchingEnabledJiraCondition
+   deriving (Eq, Show, Generic)
+
+instance ToJSON JIRACondition where
+   toJSON = toJSON . dropSuffixAndSnakeCase "JiraCondition" . show
+
+-- | The conditions that have been provided by Confluence. Please see the single condition documentation for more details:
+-- <https://developer.atlassian.com/static/connect/docs/modules/fragment/single-condition.html>
+data ConfluenceCondition
+   = ActiveThemeConfluenceCondition
+   | CanEditSpaceStylesConfluenceCondition
+   | CanSignupConfluenceCondition
+   | ContentHasAnyPermissionsSetConfluenceCondition
+   | CreateContentConfluenceCondition
+   | EmailAddressPublicConfluenceCondition
+   | FavouritePageConfluenceCondition
+   | FavouriteSpaceConfluenceCondition
+   | FeatureFlagConfluenceCondition
+   | FollowingTargetUserConfluenceCondition
+   | HasAttachmentConfluenceCondition
+   | HasBlogPostConfluenceCondition
+   | HasPageConfluenceCondition
+   | HasSpaceConfluenceCondition
+   | HasTemplateConfluenceCondition
+   | LatestVersionConfluenceCondition
+   | NotPersonalSpaceConfluenceCondition
+   | PrintableVersionConfluenceCondition
+   | ShowingPageAttachmentsConfluenceCondition
+   | SpaceFunctionPermissionConfluenceCondition
+   | SpaceSidebarConfluenceCondition
+   | TargetUserCanSetStatusConfluenceCondition
+   | TargetUserHasPersonalBlogConfluenceCondition
+   | TargetUserHasPersonalSpaceConfluenceCondition
+   | ThreadedCommentsConfluenceCondition
+   | TinyUrlSupportedConfluenceCondition
+   | UserCanCreatePersonalSpaceConfluenceCondition
+   | UserCanUpdateUserStatusConfluenceCondition
+   | UserCanUseConfluenceConfluenceCondition
+   | UserFavouritingTargetUserPersonalSpaceConfluenceCondition
+   | UserHasPersonalBlogConfluenceCondition
+   | UserHasPersonalSpaceConfluenceCondition
+   | UserIsAdminConfluenceCondition
+   | UserIsConfluenceAdministratorConfluenceCondition
+   | UserIsLoggedInConfluenceCondition
+   | UserIsSysadminConfluenceCondition
+   | UserLoggedInEditableConfluenceCondition
+   | UserWatchingPageConfluenceCondition
+   | UserWatchingSpaceConfluenceCondition
+   | UserWatchingSpaceForContentTypeConfluenceCondition
+   | ViewingContentConfluenceCondition
+   | ViewingOwnProfileConfluenceCondition
+   deriving (Eq, Show, Generic)
+
+instance ToJSON ConfluenceCondition where
+   toJSON = toJSON . dropSuffixAndSnakeCase "ConfluenceCondition" . show
diff --git a/src/Data/Connect/Descriptor.hs b/src/Data/Connect/Descriptor.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Descriptor.hs
@@ -0,0 +1,206 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{-|
+Module      : Data.Connect.Descriptor
+Description : Defines a strictly typed Atlassian Connect add-on descriptor.
+Copyright   : (c) Robert Massioli, 2014
+License     : APACHE-2
+Maintainer  : rmassaioli@atlassian.com
+Stability   : experimental
+
+This module provides the data types to let you write your own typesafe Atlassian Connect descriptor and it comes with
+Aeson bindings so that you can easily convert into json: the format that the Atlassian Connect framework expects.
+
+Atlassian Connect is a framework for writing Add-on's that can run inside the Atlassian Cloud products. You can find more
+information from the Atlassian Connect documentation <https://developer.atlassian.com/static/connect/docs/guides/introduction.html>.
+
+The plugin descriptor is defined by the 'Plugin' class. The end result of using this Haskell Module should be for you to
+end up with a valid 'Plugin'. To turn your plugin into JSON that the Atlassian marketplace can accept just use the 'encode'
+function from the Aeson library. For example, here in an example Atlassian Connect Descriptor:
+
+> pluginToJsonString :: Plugin -> ByteString
+> pluginToJsonString = encode
+>
+> exampleDescriptor :: Plugin
+> exampleDescriptor = (pluginDescriptor (PluginKey "my-example-connect") baseURL (Authentication Jwt))
+>     { pluginName = Just . Name $ "My Example Connect Addon"
+>     , pluginDescription = Just "This is an example connect descriptor."
+>     , vendor = Just $ Vendor (Name "Awesome Devs") (toURI "http://awesome-devs.com")
+>     , lifecycle = Just defaultLifecycle
+>     , modules = Just exampleModules
+>     , enableLicensing = Just False
+>     , links = fromList
+>         [ ("documentation", toURI "http://awesome-devs.com/docs")
+>         , ("source", toURI "http://bitbucket.org/awesome-devs/connect-addon")
+>         ]
+>     , scopes = Just [Read, Admin]
+>     }
+>
+> exampleModules :: Modules
+> exampleModules = Modules exampleJIRAModules emptyConfluenceModules
+>
+> exampleJIRAModules :: JIRAModules
+> exampleJIRAModules = emptyJIRAModules
+>     { jiraWebPanels =
+>         [ WebPanel
+>             { wpKey = "test-web-panel"
+>             , wpName = Name "Test Web Panel"
+>             , wpLocation = "some-location-in-jira"
+>             , wpUrl = "/panel/location/for"
+>             , wpConditions = [staticJiraCondition UserIsAdminJiraCondition]
+>             }
+>         ]
+>     , jiraGeneralPages =
+>         [ GeneralPage
+>             { generalPageKey = "test-general-page"
+>             , generalPageName = Name "Test General Page"
+>             , generalPageLocation = Just "some-other-location-in-jira"
+>             , generalPageWeight = Just 1234
+>             , generalPageUrl = "/panel/general-page"
+>             , generalPageIcon = Just IconDetails
+>                 { iconUrl = "/static/path/to/icon.png"
+>                 , iconWidth = Just 20
+>                 , iconHeight = Just 40
+>                 }
+>             , generalPageConditions = [staticJiraCondition UserHasIssueHistoryJiraCondition]
+>             }
+>         ]
+>     , jiraWebhooks =
+>         [ Webhook
+>             { webhookEvent = JiraIssueDeleted
+>             , webhookUrl = "/webhook/handle-deletion"
+>             }
+>         ]
+>     }
+
+You can use this library to make your own. This library will experience change whenever the Atlassian Connect descriptor
+changes. There are likely to be many breaking changes but we will keep track of them using the standard Haskell version
+structure.
+-}
+module Data.Connect.Descriptor (
+   -- * Atlassian Connect Add-on Descriptor
+     Plugin(..)
+   , pluginDescriptor
+   -- * Basic Types
+   , Name(..)
+   , Key(..)
+   , PluginKey(..)
+   , Timeout(..)
+   , IconDetails(..)
+   -- * Authentication
+   , Authentication(..)
+   , AuthType(..)
+   -- * Vendor Details
+   , Vendor(..)
+   -- * Lifecycle
+   , Lifecycle(..)
+   , emptyLifecycle
+   , defaultLifecycle
+   -- * Add-on Modules
+   , Modules(..)
+   , JIRAModules(..)
+   , emptyJIRAModules
+   , ConfluenceModules(..)
+   , emptyConfluenceModules
+   , WebPanel(..)
+   , GeneralPage(..)
+   -- ** Webhooks
+   , Webhook(..)
+   , WebhookEvent(..)
+   -- * Module Conditions
+   , Condition(..)
+   , ConditionType(..)
+   , ConditionSource(..)
+   , remoteCondition
+   , JIRACondition(..)
+   , staticJiraCondition
+   , ConfluenceCondition(..)
+   , staticConfluenceCondition
+   , invertCondition
+   -- * Scopes (Permissions)
+   , ProductScope(..)
+   ) where
+
+import           Data.Aeson
+import           Data.Aeson.Types
+import           Data.Connect.AesonHelpers
+import           Data.Connect.BaseTypes
+import           Data.Connect.Conditions
+import           Data.Connect.Lifecycle
+import           Data.Connect.Modules
+import           Data.Connect.Scopes
+import           Data.Connect.Webhooks
+import qualified Data.HashMap.Strict       as HM
+import           Data.Text
+import           GHC.Generics
+import           Network.URI
+
+-- | A 'Plugin' is the end result of an Atlassian Connect descriptor. It is what you should provide to the Atlassian
+-- Marketplace in order to register your plugin and it is what your Atlassian Cloud customers will download to install
+-- your Add-on via the Atlassian UPM (Universal Plugin Manager). Only a very small number of fields are strictly required
+-- to generate a valid Atlassian Connect plugin descriptor. Everything that is optional is marked by a maybe type.
+--
+-- Even though we provide documentation here you shoucd check the Atlassian Connect Descriptor documentation if you want
+-- to get accurate information on the contents of a plugin: <https://developer.atlassian.com/static/connect/docs/modules/>
+data Plugin = Plugin
+   { pluginKey         :: PluginKey -- ^ Plugin keys are required. The important detail about this key is that it should
+                                    -- be unique across the Atlassian Marketplace. For example, a good key might be
+                                    -- @com.yourcompanyorpersonalname.youraddonname@ because it would be unique in the
+                                    -- marketplace.
+   , pluginBaseUrl     :: URI -- ^ Every plugin must specify a base url where all other relative URI's in the plugin will
+                              -- call to in production. This is the url that the Atlassian Marketplace will query for your
+                              -- descriptor and the url that your customers will come in on. This is especially important
+                              -- for load balanced applications and will also likely be different in your staging and production
+                              -- environments.
+   , authentication    :: Authentication -- ^ The authentication type that you plugin requires. See 'Authentication' for more details.
+   , pluginName        :: Maybe (Name Plugin) -- ^ While your add-on does not require a name it is strongly recommended
+                                              -- as this is the human readable name that will appear in the UPM amongst other places.
+   , pluginDescription :: Maybe Text -- ^ You should give your add-on a description. This description will appear in multiple
+                                     -- locations on the Atlassian Marketplace and UPM and will be used to explain what your
+                                     -- add-on does.
+   , vendor            :: Maybe Vendor -- ^ You are the 'Vendor'. Put your details here!
+   , lifecycle         :: Maybe Lifecycle -- ^ Atlassian Connect addon's have a lifecycle. Register your handlers for
+                                          -- the 'Lifecycle' events here so that you can tell, for example, when your
+                                          -- addon is installed or enabled.
+   , modules           :: Maybe Modules -- ^ The modules that your Atlassian Connect add-on provides to the Cloud application. Look at the 'Modules' documentaiton for more information.
+   , apiVersion        :: Maybe Text -- ^ Required if you wish to provide new versions of your addon to a subset of beta customers.
+   , enableLicensing   :: Maybe Bool -- ^ If you are giving away a free add-on then you can set this to false, otherwise set it to true.
+   , links             :: HM.HashMap Text URI -- ^ A collection of custom links that you wish to publish with your add-on. Like documentation or bug-tracking links.
+   , scopes            :: Maybe [ProductScope] -- ^ The scopes that your add-on requires. See 'ProductScope' for more information.
+   } deriving (Show, Generic)
+
+instance ToJSON (Name Plugin)
+
+instance ToJSON Plugin where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "plugin"
+      }
+
+-- | A helper method to generate a bare-bones Atlassian Connect add-on by providing only the absolutely required fields.
+-- You can then use Haskell record syntax to update the plugin with more details. For example:
+--
+-- > (pluginDescriptor (PluginKey . pack $ "com.company.mycoolplugin") (fromJust . parseURI $ "http://mycoolplugin.company.com") (Authentication Jwt))
+-- >    { pluginName = Just . Name . pack $ "My Cool Plugin"
+-- >    , pluginDescription = Just . pack $ "Chil and be cool, you have a plugin descriptor."
+-- >    }
+pluginDescriptor
+   :: PluginKey -- ^ The key for your add-on.
+   -> URI -- ^ The base url for your add-on.
+   -> Authentication -- ^ The authentication that your add-on requires.
+   -> Plugin -- ^ A bare-bones Atlassian Connect descriptor.
+pluginDescriptor key' url' auth = Plugin
+   { pluginKey = key'
+   , pluginBaseUrl = url'
+   , authentication = auth
+   , pluginName = Nothing
+   , pluginDescription = Nothing
+   , vendor = Nothing
+   , apiVersion = Nothing
+   , modules = Nothing
+   , enableLicensing = Nothing
+   , lifecycle = Nothing
+   , links = HM.empty
+   , scopes = Nothing
+   }
diff --git a/src/Data/Connect/Lifecycle.hs b/src/Data/Connect/Lifecycle.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Lifecycle.hs
@@ -0,0 +1,47 @@
+{-# LANGUAGE DeriveGeneric #-}
+module Data.Connect.Lifecycle
+    ( Lifecycle(..)
+    , emptyLifecycle
+    , defaultLifecycle
+    ) where
+
+import           Data.Aeson
+import           Data.Connect.AesonHelpers
+import           Data.Connect.OrphanInstances ()
+import           GHC.Generics
+import qualified Network.URI                  as NU
+
+-- | Every Atlassian Connect add-on can be installed, uninstalled, enabled and disabled. These are known as 'Lifecycle'
+-- events. These events will fire on each and every Cloud instance that your add-on is installed on. You can request in
+-- your Atlassian Connect add-on descriptor to be alerted of lifecycle events. When the event fires, if you have
+-- requested it, you will be given the details of the event in a JSON blob by the host application.
+--
+-- The lifecycle events are documented fully in the Atlassian Connect documentation:
+-- <https://developer.atlassian.com/static/connect/docs/modules/lifecycle.html>
+--
+-- It is important to note that the installed event is particularily important to any Atlassian Connect add-on that
+-- needs to use 'Jwt' auth tokens because the installed handler will come with the shared secret for your add-on on
+-- that particular instance.
+data Lifecycle = Lifecycle
+   { installed   :: Maybe NU.URI -- ^ Potential relative URI to call every time an add-on is installed on an instance.
+   , uninstalled :: Maybe NU.URI -- ^ Potential relative URI to call every time an add-on is uninstalled on an instance.
+   , enabled     :: Maybe NU.URI -- ^ Potential relative URI to call every time an add-on is enabled on an instance.
+   , disabled    :: Maybe NU.URI -- ^ Potential relative URI to call every time an add-on is disabled on an instance.
+   } deriving (Show, Generic) -- TODO
+
+instance ToJSON Lifecycle where
+   toJSON = genericToJSON baseOptions
+
+-- | The empty 'Lifecycle' allowing you to specify exactly which events you wish to handle with Haskell record syntax.
+emptyLifecycle :: Lifecycle
+emptyLifecycle = Lifecycle Nothing Nothing Nothing Nothing
+
+-- | The default 'Lifecycle' where installed goes to /installed and so on and so forth for every lifecycle event. You
+-- can choose to disclude certain events by 'Nothing' them out.
+defaultLifecycle :: Lifecycle
+defaultLifecycle = Lifecycle
+   { installed = NU.parseRelativeReference "/installed"
+   , uninstalled = NU.parseRelativeReference "/uninstalled"
+   , enabled = NU.parseRelativeReference "/enabled"
+   , disabled = NU.parseRelativeReference "/disabled"
+   }
diff --git a/src/Data/Connect/Modules.hs b/src/Data/Connect/Modules.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Modules.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE FlexibleInstances #-}
+module Data.Connect.Modules
+   ( Modules(..)
+   , JIRAModules(..)
+   , emptyJIRAModules
+   , ConfluenceModules(..)
+   , emptyConfluenceModules
+   , WebPanel(..)
+   , GeneralPage(..)
+   ) where
+
+import           Data.Aeson
+import           Data.Aeson.Types
+import           Data.Connect.AesonHelpers
+import           Data.Connect.BaseTypes
+import           Data.Connect.Conditions
+import           Data.Connect.Webhooks
+import qualified Data.HashMap.Strict       as HM
+import qualified Data.Text                 as T
+import           GHC.Generics
+
+-- | 'Modules' are perhaps the most important part of your Atlassian Connect descriptor. They specify which parts of the
+-- host application you wish to inject content into. They provide your entry point into the host application.
+--
+-- Atlassian Connect provides a large set of pre-defined entry points into the host application. Some of which are common
+-- to every application and some of which are unique to the particular application that you are targeting:
+--
+-- * To see the JIRA modules: <https://developer.atlassian.com/static/connect/docs/modules/jira/index.html>
+-- * To see the Confluence modules: <https://developer.atlassian.com/static/connect/docs/modules/confluence/index.html>
+--
+-- Note: One important point about modules: they must all have a key and that key must be unique inside the same Atlassian
+-- Connect addon.
+data Modules = Modules
+   { jiraModules       :: JIRAModules -- ^ All of the JIRA Modules that you wish to define.
+   , confluenceModules :: ConfluenceModules -- ^ All of the Confluence modules that you wish to define.
+   } deriving (Show, Generic)
+
+instance ToJSON Modules where
+   toJSON modules = case (jm, cm) of
+      (Object jiraObject, Object confluenceObject) -> Object $ HM.union jiraObject confluenceObject
+      _ -> Null
+      where
+         jm = toJSON . jiraModules $ modules
+         cm = toJSON . confluenceModules $ modules
+
+-- TODO use Endo Modules to add modules for multiple different products to the modules list
+
+-- | A collection of all of the JIRA Modules that you can define. For more documentation on which Modules are supported
+-- the Atlassian Connect framework please see 'Modules'. You can also find more documentation on each of the modules.
+data JIRAModules = JIRAModules
+   { jiraWebPanels    :: [WebPanel]
+   , jiraGeneralPages :: [GeneralPage]
+   , jiraWebhooks     :: [Webhook]
+   } deriving (Show, Generic)
+
+instance ToJSON JIRAModules where
+   toJSON = genericToJSON baseOptions
+
+-- | A collection of all of the Confluence Modules that you can define. For more documentation on which Modules are supported
+-- the Atlassian Connect framework please see 'Modules'. You can also find more documentation on each of the modules.
+data ConfluenceModules = ConfluenceModules
+   { confluenceWebPanels :: [WebPanel]
+   } deriving (Show, Generic)
+
+instance ToJSON ConfluenceModules where
+   toJSON = genericToJSON baseOptions
+
+-- | Empty JIRA Modules; useful when you only want to define a few modules via Haskell record syntax.
+emptyJIRAModules :: JIRAModules
+emptyJIRAModules = JIRAModules [] [] []
+
+-- | Empty Confluence Modules; useful when you only want to define a few modules via Haskell record syntax.
+emptyConfluenceModules :: ConfluenceModules
+emptyConfluenceModules = ConfluenceModules []
+
+-- | A 'WebPanel' is an injectable segment of the host application that you can place content inside. Currently the
+-- WebPanel has the same structure for both JIRA and Confluence but, potentially, that could change in the future.
+-- You can read their Atlassian Connect documentation here:
+--
+-- * JIRA Web panels: <https://developer.atlassian.com/static/connect/docs/modules/jira/web-panel.html>
+-- * Confluence Web panels: <https://developer.atlassian.com/static/connect/docs/modules/confluence/web-panel.html>
+--
+-- Here is what an example Hello World web panel might look like:
+--
+-- > helloWorldWebPanel = WebPanel
+-- >    { wpKey = "hello-world"
+-- >    , wpName = Name "Hello world!"
+-- >    , wpUrl = "/panel/show-hello-world"
+-- >    , wpLocation = "atl.jira.view.issue.right.context"
+-- >    , wpConditions = [staticJiraCondition UserIsLoggedInJiraCondition]
+-- >    }
+-- >    where
+-- >       toURI = fromJust . parseRelativeReference
+--
+-- WebPanels are a great way to inject your add-on's content into the host application.
+data WebPanel = WebPanel
+   { wpKey        :: T.Text -- ^ The add-on unique key for this module.
+   , wpName       :: Name WebPanel -- ^ The name of this panel, likely to appear in the User Interface.
+   , wpUrl        :: T.Text -- ^ The relative URI that the host product will hit to get HTML content.
+   , wpLocation   :: T.Text -- ^ The location that this content should be injected in the host product.
+   , wpConditions :: [Condition] -- ^ The 'Condition's that need to be met for this module to be displayed.
+   } deriving (Show, Generic)
+
+instance ToJSON WebPanel where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "wp"
+      }
+
+-- | A 'GeneralPage' makes your add-on take a large section of screen realestate, with the intention of displaying
+-- your own page with no other distracting content. This is very useful for pages like Configuration screens or
+-- Statistics pages where you really want the user to be fully working with your add-on inside the host product.
+--
+-- General pages, like Web Panels, are common to JIRA and Confluence and share the same json format. However they have
+-- separate documentation:
+--
+-- * JIRA General Page: <https://developer.atlassian.com/static/connect/docs/modules/jira/general-page.html>
+-- * Confluence General Page: <https://developer.atlassian.com/static/connect/docs/modules/confluence/general-page.html>
+--
+-- Even though, at this point in time, the documentation looks identical.
+data GeneralPage = GeneralPage
+   { generalPageKey        :: T.Text -- ^ The add-on unique key for this module.
+   , generalPageName       :: Name GeneralPage -- ^ The name of this General Page. Likely to be used in the page title.
+   , generalPageUrl        :: T.Text -- ^ The relative URI that the host product will hit to get the HTML content for the page.
+   , generalPageLocation   :: Maybe T.Text -- ^ The location for this General Page to display; see the docs for your options.
+   , generalPageIcon       :: Maybe IconDetails -- ^ The optional icon to use for this general page.
+   , generalPageWeight     :: Maybe Integer -- ^ Determines the order that this item appears in any menu or list.
+                                            -- Lower numbers mean that it will appear higher in the list.
+   , generalPageConditions :: [Condition] -- ^ The 'Condition's that need to be met for this module to be displayed.
+   } deriving (Show, Generic)
+
+instance ToJSON GeneralPage where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "generalPage"
+      }
+
+instance ToJSON (Name WebPanel) where
+   toJSON = nameToValue
+
+instance ToJSON (Name GeneralPage) where
+   toJSON = nameToValue
+
+nameToValue :: Name a -> Value
+nameToValue (Name name) = object [ T.pack "value" .= name ]
diff --git a/src/Data/Connect/OrphanInstances.hs b/src/Data/Connect/OrphanInstances.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/OrphanInstances.hs
@@ -0,0 +1,13 @@
+module Data.Connect.OrphanInstances where
+
+import           Control.Monad (mzero)
+import           Data.Aeson
+import qualified Data.Text     as T
+import qualified Network.URI   as NU
+
+instance FromJSON NU.URI where
+   parseJSON (String uriString) = maybe mzero return (NU.parseURI . T.unpack $ uriString)
+   parseJSON _ = mzero
+
+instance ToJSON NU.URI where
+   toJSON = String . T.pack . show
diff --git a/src/Data/Connect/Scopes.hs b/src/Data/Connect/Scopes.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Scopes.hs
@@ -0,0 +1,43 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Data.Connect.Scopes
+    ( ProductScope(..)
+    ) where
+
+import           Control.Monad (mzero)
+import           Data.Aeson
+import           GHC.Generics
+
+-- | Scopes are an Atlassian Connect concept that declare how much access your addon requires to any give Cloud instance.
+-- These scopes can be thought of as permissions are are well documented:
+-- <https://developer.atlassian.com/static/connect/docs/scopes/scopes.html>
+--
+-- It is important to note that these scopes only give you restricted access to certain REST resources. You can not query
+-- any REST url as you would with an Atlassian Server plugin. The restricted set of REST resources per application can be
+-- found in the Atlassian Connect documentation.
+data ProductScope
+  = Read -- ^ The read scope means that you can pull data from the Cloud application.
+  | Write -- ^ The write scope gives you the same access as a regular user of the Atlassian connect application.
+  | Delete -- ^ The delete scope is required if you want to perform potentially destructive operations on data.
+  | ProjectAdmin -- ^ A JIRA specific scope. Lets your add-on administer a project in JIRA.
+  | SpaceAdmin -- ^ A Confluence specific scope. Lets your add-on administer a space in Confluence.
+  | Admin -- ^ Gives your Atlassian Connect add-on administrative rights to the Cloud instance. (But NOT system
+          -- administrator permission. Happily you cannot request that level of access.)
+  deriving (Show, Eq, Generic)
+
+instance ToJSON ProductScope where
+   toJSON Read         = "read"
+   toJSON Write        = "write"
+   toJSON Delete       = "delete"
+   toJSON ProjectAdmin = "project_admin"
+   toJSON SpaceAdmin   = "space_admin"
+   toJSON Admin        = "admin"
+
+instance FromJSON ProductScope where
+  parseJSON (String "read")             = return Read
+  parseJSON (String "write")            = return Write
+  parseJSON (String "delete")           = return Delete
+  parseJSON (String "project_admin")    = return ProjectAdmin
+  parseJSON (String "space_admin")      = return SpaceAdmin
+  parseJSON (String "admin")            = return Admin
+  parseJSON _                           = mzero
diff --git a/src/Data/Connect/Webhooks.hs b/src/Data/Connect/Webhooks.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Connect/Webhooks.hs
@@ -0,0 +1,152 @@
+{-# LANGUAGE DeriveGeneric     #-}
+{-# LANGUAGE OverloadedStrings #-}
+module Data.Connect.Webhooks
+   ( Webhook(..)
+   , WebhookEvent(..)
+   ) where
+
+import           Data.Aeson
+import           Data.Aeson.Types
+import           Data.Connect.AesonHelpers
+import           Data.Connect.OrphanInstances ()
+import qualified Data.Text                    as T
+import           GHC.Generics
+
+-- | When users of the host application perform updates your Atlassian Connect add-on will not be alerted /unless/
+-- it listens to the 'WebhookEvent's coming from that application. Webhooks are the way to close the issue recency loop
+-- in the Atlassian products. It is important to note that Webhooks are 'best effort' and that there is no guarantee
+-- that the webhook will make it to your Atlassian Connect application.
+--
+-- The Atlassian connect webhook documentation explains this in more detail:
+-- <https://developer.atlassian.com/static/connect/docs/modules/jira/webhook.html>
+data Webhook = Webhook
+   { webhookEvent :: WebhookEvent -- ^ The event that you want your Atlassian Connect add-on to watch.
+   , webhookUrl   :: T.Text -- ^ The relative URI that you wish to handle the webhook response.
+   } deriving (Show, Generic)
+
+instance ToJSON Webhook where
+   toJSON = genericToJSON baseOptions
+      { fieldLabelModifier = stripFieldNamePrefix "webhook"
+      }
+
+-- | The webhook event that you wish to watch from your Atlassian Connect add-on.
+data WebhookEvent
+   = ConnectAddonDisabled
+   | ConnectAddonEnabled
+   | JiraWebhookPostFunction
+   | JiraIssueCreated
+   | JiraIssueDeleted
+   | JiraIssueUpdated
+   | JiraWorklogUpdated
+   | JiraPluginEnabled
+   | JiraPluginsUpgraded
+   | JiraRemoteIssueLinkAggregateClearedEvent
+   | JiraRemoteWorkflowPostFunction
+   | ConfluenceAttachmentCreated
+   | ConfluenceAttachmentRemoved
+   | ConfluenceAttachmentUpdated
+   | ConfluenceAttachmentViewed
+   | ConfluenceBlogCreated
+   | ConfluenceBlogRemoved
+   | ConfluenceBlogRestored
+   | ConfluenceBlogTrashed
+   | ConfluenceBlogUpdated
+   | ConfluenceBlogViewed
+   | ConfluenceCacheStatisticsChanged
+   | ConfluenceCommentCreated
+   | ConfluenceCommentRemoved
+   | ConfluenceCommentUpdated
+   | ConfluenceContentPermissionsUpdated
+   | ConfluenceLabelAdded
+   | ConfluenceLabelCreated
+   | ConfluenceLabelDeleted
+   | ConfluenceLabelRemoved
+   | ConfluenceLogin
+   | ConfluenceLoginFailed
+   | ConfluenceLogout
+   | ConfluencePageChildrenReordered
+   | ConfluencePageCreated
+   | ConfluencePageMoved
+   | ConfluencePageRemoved
+   | ConfluencePageRestored
+   | ConfluencePageTrashed
+   | ConfluencePageUpdated
+   | ConfluencePageViewed
+   | ConfluenceSearchPerformed
+   | ConfluenceSpaceCreated
+   | ConfluenceSpaceLogoUpdated
+   | ConfluenceSpacePermissionsUpdated
+   | ConfluenceSpaceRemoved
+   | ConfluenceSpaceUpdated
+   | ConfluenceStatusCleared
+   | ConfluenceStatusCreated
+   | ConfluenceStatusRemoved
+   | ConfluenceUserCreated
+   | ConfluenceUserDeactivated
+   | ConfluenceUserFollowed
+   | ConfluenceUserReactivated
+   | ConfluenceUserRemoved
+   | ConfluenceGroupCreated
+   | ConfluenceGroupRemoved
+   | ServerUpgraded
+   deriving (Show)
+
+instance ToJSON WebhookEvent where
+   toJSON ConnectAddonDisabled = String "connect_addon_disabled"
+   toJSON ConnectAddonEnabled = String "connect_addon_enabled"
+   toJSON JiraWebhookPostFunction = String "jira-webhook-post-function"
+   toJSON JiraIssueCreated = String "jira:issue_created"
+   toJSON JiraIssueDeleted = String "jira:issue_deleted"
+   toJSON JiraIssueUpdated = String "jira:issue_updated"
+   toJSON JiraWorklogUpdated = String "jira:worklog_updated"
+   toJSON JiraPluginEnabled = String "plugin_enabled"
+   toJSON JiraPluginsUpgraded = String "plugins_upgraded"
+   toJSON JiraRemoteIssueLinkAggregateClearedEvent = String "remote_issue_link_aggregate_cleared_event"
+   toJSON JiraRemoteWorkflowPostFunction = String "remote_workflow_post_function"
+   toJSON ConfluenceAttachmentCreated = "attachment_created"
+   toJSON ConfluenceAttachmentRemoved = "attachment_removed"
+   toJSON ConfluenceAttachmentUpdated = "attachment_updated"
+   toJSON ConfluenceAttachmentViewed = "attachment_viewed"
+   toJSON ConfluenceBlogCreated = "blog_created"
+   toJSON ConfluenceBlogRemoved = "blog_removed"
+   toJSON ConfluenceBlogRestored = "blog_restored"
+   toJSON ConfluenceBlogTrashed = "blog_trashed"
+   toJSON ConfluenceBlogUpdated = "blog_updated"
+   toJSON ConfluenceBlogViewed = "blog_viewed"
+   toJSON ConfluenceCacheStatisticsChanged = "cache_statistics_changed"
+   toJSON ConfluenceCommentCreated = "comment_created"
+   toJSON ConfluenceCommentRemoved = "comment_removed"
+   toJSON ConfluenceCommentUpdated = "comment_updated"
+   toJSON ConfluenceContentPermissionsUpdated = "content_permissions_updated"
+   toJSON ConfluenceLabelAdded = "label_added"
+   toJSON ConfluenceLabelCreated = "label_created"
+   toJSON ConfluenceLabelDeleted = "label_deleted"
+   toJSON ConfluenceLabelRemoved = "label_removed"
+   toJSON ConfluenceLogin = "login"
+   toJSON ConfluenceLoginFailed = "login_failed"
+   toJSON ConfluenceLogout = "logout"
+   toJSON ConfluencePageChildrenReordered = "page_children_reordered"
+   toJSON ConfluencePageCreated = "page_created"
+   toJSON ConfluencePageMoved = "page_moved"
+   toJSON ConfluencePageRemoved = "page_removed"
+   toJSON ConfluencePageRestored = "page_restored"
+   toJSON ConfluencePageTrashed = "page_trashed"
+   toJSON ConfluencePageUpdated = "page_updated"
+   toJSON ConfluencePageViewed = "page_viewed"
+   toJSON ConfluenceSearchPerformed = "search_performed"
+   toJSON ConfluenceSpaceCreated = "space_created"
+   toJSON ConfluenceSpaceLogoUpdated = "space_logo_updated"
+   toJSON ConfluenceSpacePermissionsUpdated = "space_permissions_updated"
+   toJSON ConfluenceSpaceRemoved = "space_removed"
+   toJSON ConfluenceSpaceUpdated = "space_updated"
+   toJSON ConfluenceStatusCleared = "status_cleared"
+   toJSON ConfluenceStatusCreated = "status_created"
+   toJSON ConfluenceStatusRemoved = "status_removed"
+   toJSON ConfluenceUserCreated = "user_created"
+   toJSON ConfluenceUserDeactivated = "user_deactivated"
+   toJSON ConfluenceUserFollowed = "user_followed"
+   toJSON ConfluenceUserReactivated = "user_reactivated"
+   toJSON ConfluenceUserRemoved = "user_removed"
+   toJSON ConfluenceGroupCreated = "group_created"
+   toJSON ConfluenceGroupRemoved = "group_removed"
+   toJSON ServerUpgraded = String "server_upgraded"
diff --git a/tests/AssertionHelpers.hs b/tests/AssertionHelpers.hs
new file mode 100644
--- /dev/null
+++ b/tests/AssertionHelpers.hs
@@ -0,0 +1,8 @@
+module AssertionHelpers where
+
+import           Test.HUnit
+
+isEqualTo :: (Eq a, Show a) => IO a -> a -> Assertion
+isEqualTo actual expected = do
+    value <- actual
+    value @?= expected
diff --git a/tests/ConditionsTest.hs b/tests/ConditionsTest.hs
new file mode 100644
--- /dev/null
+++ b/tests/ConditionsTest.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE OverloadedStrings #-}
+module ConditionsTest (conditionsTests) where
+
+import           AssertionHelpers
+import           Control.Monad
+import           Data.Aeson
+import           Data.Connect.Conditions
+import qualified Data.Text               as T
+import           Test.HUnit
+import           ValueExtractors
+import qualified Data.HashMap.Strict as HM
+
+conditionsTests :: Test
+conditionsTests = TestList
+    [ invertConditionSimple
+    , invertConditionAndType
+    , invertConditionOrType
+    , invertConditionNested
+    , jiraConditionJsonCorrect
+    , confluenceConditionJsonCorrect
+    , remoteConditionJsonCorrect
+    , compositeAndConditionConnect
+    , conditionParamsTest
+    ]
+
+-- Inverting conditions works correctly
+invertConditionSimple :: Test
+invertConditionSimple = TestCase $ do
+    let originalCondition = staticJiraCondition UserIsAdminJiraCondition
+    (not . conditionInverted $ originalCondition) @? "Expected the condition to not be inverted on creation."
+    let invertedCondition = invertCondition originalCondition
+    conditionInverted invertedCondition @? "Expected the condition to be inverted."
+
+threeChildConditions :: [Condition]
+threeChildConditions =
+    [ invertCondition $ staticJiraCondition UserHasIssueHistoryJiraCondition
+    , staticJiraCondition IsWatchingIssueJiraCondition
+    , staticJiraCondition HasVotedForIssueJiraCondition
+    ]
+
+invertConditionAndType :: Test
+invertConditionAndType = TestCase $ do
+    let invertedCondition = invertCondition $ CompositeCondition threeChildConditions AndCondition
+    OrCondition @=? conditionType invertedCondition
+    [False, True, True] @=? (fmap conditionInverted . subConditions $ invertedCondition)
+
+invertConditionOrType :: Test
+invertConditionOrType = TestCase $ do
+    let invertedCondition = invertCondition $ CompositeCondition threeChildConditions OrCondition
+    AndCondition @=? conditionType invertedCondition
+    [False, True, True] @=? (fmap conditionInverted . subConditions $ invertedCondition)
+
+invertConditionNested :: Test
+invertConditionNested = TestCase $ do
+    let child1 = invertCondition $ CompositeCondition threeChildConditions OrCondition
+    let child2 = CompositeCondition threeChildConditions OrCondition
+    let child3 = CompositeCondition threeChildConditions AndCondition
+    let invertedCondition = invertCondition $ CompositeCondition [child1, child2, child3] AndCondition
+    OrCondition @=? conditionType invertedCondition
+    [OrCondition, AndCondition, OrCondition] @=? (fmap conditionType . subConditions $ invertedCondition)
+    [[True, False, False], [False, True, True], [False, True, True]] @=? (fmap (fmap conditionInverted . subConditions) . subConditions $ invertedCondition)
+
+-- JIRA Conditions come out correctly
+jiraConditionJsonCorrect :: Test
+jiraConditionJsonCorrect = TestCase $ do
+    let condition = staticJiraCondition UserIsAdminJiraCondition
+    let jsonValue = toJSON condition
+    isObject jsonValue @? "Expect the result to be an object"
+    (getString =<< get "condition" jsonValue) `isEqualTo` "user_is_admin"
+    (return . not =<< getBool =<< get "invert" jsonValue) @? "Expected the condition to not be inverted"
+
+-- Confluence Conditions come out correctly
+confluenceConditionJsonCorrect :: Test
+confluenceConditionJsonCorrect = TestCase $ do
+    let condition = invertCondition $ staticConfluenceCondition HasAttachmentConfluenceCondition
+    let jsonValue = toJSON condition
+    isObject jsonValue @? "Expect the result to be an object"
+    (getString =<< get "condition" jsonValue) `isEqualTo` "has_attachment"
+    (getBool =<< get "invert" jsonValue) @? "Expect the condition to be inverted"
+
+-- Remote conditions come out correctly
+remoteConditionJsonCorrect :: Test
+remoteConditionJsonCorrect = TestCase $ do
+    let remoteUrl = "/rest/condition/is-awesome"
+    let condition = invertCondition $ remoteCondition remoteUrl
+    let jsonValue = toJSON condition
+    isObject jsonValue @? "Expect the remote condition to come out as an object."
+    (getString =<< get "condition" jsonValue) `isEqualTo` T.pack remoteUrl
+    (getBool =<< get "invert" jsonValue) @? "Expect the condition to be inverted"
+
+-- CompositeCondition's are rendered correctly
+compositeAndConditionConnect :: Test
+compositeAndConditionConnect = TestCase $ do
+    let condition = CompositeCondition threeChildConditions AndCondition
+    let jsonValue = toJSON condition
+    isObject jsonValue @? "Expect the conditions in an object."
+    conditions <- getArray =<< get "conditions" jsonValue
+    sequence (fmap (getString <=< get "condition") conditions) `isEqualTo` ["user_has_issue_history","is_watching_issue","has_voted_for_issue"]
+    get "type" jsonValue `isEqualTo` "AND"
+
+conditionParamsTest :: Test
+conditionParamsTest = TestCase $ do
+    let condition = staticJiraCondition UserIsAdminJiraCondition
+    let conditionWithParams = condition { conditionParams = HM.fromList [("param-one", "value-one"), ("param-two", "value-two")] }
+    let jv = toJSON conditionWithParams
+    (getString =<< get "param-one" =<< get "params" jv) `isEqualTo` "value-one"
+    (getString =<< get "param-two" =<< get "params" jv) `isEqualTo` "value-two"
diff --git a/tests/DescriptorTest.hs b/tests/DescriptorTest.hs
new file mode 100644
--- /dev/null
+++ b/tests/DescriptorTest.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE OverloadedStrings #-}
+module DescriptorTest (descriptorTests) where
+
+import           AssertionHelpers
+import           Control.Applicative
+import           Data.Aeson
+import           Data.Connect.Descriptor
+import           Data.HashMap.Strict
+import           Data.Maybe              (fromJust)
+import qualified Data.Text               as T
+import           Network.URI
+import           Test.HUnit
+import           ValueExtractors
+
+toURI :: String -> URI
+toURI = fromJust . parseURI
+
+baseURL :: URI
+baseURL = toURI "http://my-example.connect.com"
+
+exampleDescriptor1 :: Plugin
+exampleDescriptor1 = (pluginDescriptor (PluginKey "my-example-connect") baseURL (Authentication Jwt))
+    { pluginName = Just . Name $ "My Example Connect Addon"
+    , pluginDescription = Just "This is an example connect descriptor."
+    , vendor = Just $ Vendor (Name "Awesome Devs") (toURI "http://awesome-devs.com")
+    , lifecycle = Just defaultLifecycle
+    , modules = Just exampleModules1
+    , enableLicensing = Just False
+    , links = fromList
+        [ ("documentation", toURI "http://awesome-devs.com/docs")
+        , ("source", toURI "http://bitbucket.org/awesome-devs/connect-addon")
+        ]
+    , scopes = Just [Read, Admin]
+    }
+
+exampleModules1 :: Modules
+exampleModules1 = Modules exampleJIRAModules emptyConfluenceModules
+
+exampleJIRAModules :: JIRAModules
+exampleJIRAModules = emptyJIRAModules
+    { jiraWebPanels =
+        [ WebPanel
+            { wpKey = "test-web-panel"
+            , wpName = Name "Test Web Panel"
+            , wpLocation = "some-location-in-jira"
+            , wpUrl = "/panel/location/for"
+            , wpConditions = [staticJiraCondition UserIsAdminJiraCondition]
+            }
+        ]
+    , jiraGeneralPages =
+        [ GeneralPage
+            { generalPageKey = "test-general-page"
+            , generalPageName = Name "Test General Page"
+            , generalPageLocation = Just "some-other-location-in-jira"
+            , generalPageWeight = Just 1234
+            , generalPageUrl = "/panel/general-page"
+            , generalPageIcon = Just IconDetails
+                { iconUrl = "/static/path/to/icon.png"
+                , iconWidth = Just 20
+                , iconHeight = Just 40
+                }
+            , generalPageConditions = [staticJiraCondition UserHasIssueHistoryJiraCondition]
+            }
+        ]
+    , jiraWebhooks =
+        [ Webhook
+            { webhookEvent = JiraIssueDeleted
+            , webhookUrl = "/webhook/handle-deletion"
+            }
+        ]
+    }
+
+descriptorTests :: Test
+descriptorTests = TestList
+    [ basicDescriptorTest
+    ]
+
+basicDescriptorTest :: Test
+basicDescriptorTest = TestCase $ do
+    let jv = toJSON exampleDescriptor1
+    isObject jv @? "Expect the descriptor to be one big json object."
+    (getString =<< get "key" jv) `isEqualTo` "my-example-connect"
+    (getString =<< get "baseUrl" jv) `isEqualTo` (T.pack . show $ baseURL)
+    (getString =<< get "type" =<< get "authentication" jv) `isEqualTo` "jwt"
+    (getString =<< get "name" jv) `isEqualTo` "My Example Connect Addon"
+    (getString =<< get "description" jv) `isEqualTo` "This is an example connect descriptor."
+    (getString =<< get "name" =<< get "vendor" jv) `isEqualTo` "Awesome Devs"
+    (getString =<< get "url" =<< get "vendor" jv) `isEqualTo` "http://awesome-devs.com"
+    (isObject <$> get "lifecycle" jv) @? "Expected the lifecycle to be an object."
+    (isObject <$> get "modules" jv) @? "Expected it to be an array of modules."
+    (getString =<< get "documentation" =<< get "links" jv) `isEqualTo` "http://awesome-devs.com/docs"
+    (getString =<< get "source" =<< get "links" jv) `isEqualTo` "http://bitbucket.org/awesome-devs/connect-addon"
+    (getArray =<< get "scopes" jv) `isEqualTo` ["read", "admin"]
+    (not <$> (getBool =<< get "enableLicensing" jv)) @? "Expected licensing to be disabled."
diff --git a/tests/ModulesTest.hs b/tests/ModulesTest.hs
new file mode 100644
--- /dev/null
+++ b/tests/ModulesTest.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE OverloadedStrings #-}
+module ModulesTest (moduleTests) where
+
+import           AssertionHelpers
+import           Control.Applicative
+import           Data.Aeson
+import           Data.Connect.BaseTypes
+import           Data.Connect.Conditions (JIRACondition (..),
+                                          staticJiraCondition)
+import           Data.Connect.Modules
+import           Test.HUnit
+import           ValueExtractors
+
+moduleTests :: Test
+moduleTests = TestList
+    [ testWebPanelCorrectFormat
+    , testGeneralPageCorrectFormat
+    ]
+
+testWebPanelCorrectFormat :: Test
+testWebPanelCorrectFormat = TestCase $ do
+    let wp = WebPanel
+              { wpKey = "web-panel-key"
+              , wpName = Name "My web panel"
+              , wpUrl = "/panel/my-panel?issue_key={issue.key}"
+              , wpLocation = "atl.jira.view.issue.right.context"
+              , wpConditions = [staticJiraCondition UserIsAdminJiraCondition]
+              }
+    let jv = toJSON wp
+    isObject jv @? "Expected the web panel to be an object"
+    (getString =<< get "key" jv) `isEqualTo` "web-panel-key"
+    (getString =<< get "value" =<< get "name" jv) `isEqualTo` "My web panel"
+    (getString =<< get "url" jv) `isEqualTo` "/panel/my-panel?issue_key={issue.key}"
+    (getString =<< get "location" jv) `isEqualTo` "atl.jira.view.issue.right.context"
+    (isArray <$> get "conditions" jv) @? "Expected the conditions to be an array"
+
+testGeneralPageCorrectFormat :: Test
+testGeneralPageCorrectFormat = TestCase $ do
+    let gp = GeneralPage
+              { generalPageKey = "general-page-key"
+              , generalPageName = Name "General Page Name"
+              , generalPageUrl = "/panel/page-panel-name?page_id={page.id}"
+              , generalPageLocation = Just "some-confluence-location"
+              , generalPageIcon = Just $ IconDetails "/path/to/icon" (Just 10) (Just 20)
+              , generalPageWeight = Just 10000
+              , generalPageConditions = [staticJiraCondition IsIssueReportedByCurrentUserJiraCondition]
+              }
+    let jv = toJSON gp
+    isObject jv @? "Expected the web panel to be an object"
+    get "key" jv `isEqualTo` "general-page-key"
+    (get "value" =<< get "name" jv) `isEqualTo` "General Page Name"
+    get "url" jv `isEqualTo` "/panel/page-panel-name?page_id={page.id}"
+    get "location" jv `isEqualTo` "some-confluence-location"
+    (isObject <$> get "icon" jv) @? "Expected an icon to be present"
+    (get "url" =<< get "icon" jv) `isEqualTo` "/path/to/icon"
+    (getNumber =<< get "width" =<< get "icon" jv) `isEqualTo` 10
+    (getNumber =<< get "height" =<< get "icon" jv) `isEqualTo` 20
+    (getNumber =<< get "weight" jv) `isEqualTo` 10000
+    (isArray <$> get "conditions" jv) @? "Expected the conditions to be present."
diff --git a/tests/Test.hs b/tests/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Test.hs
@@ -0,0 +1,18 @@
+module Test where
+
+import           ConditionsTest
+import           DescriptorTest
+import qualified Distribution.TestSuite as TS
+import           ModulesTest
+import qualified Test.HUnit             as HU
+import           Util
+
+hunitTests :: HU.Test
+hunitTests = HU.TestList
+    [ HU.TestLabel "Condition Tests" conditionsTests
+    , HU.TestLabel "Module Tests" moduleTests
+    , HU.TestLabel "Descriptor Tests" descriptorTests
+    ]
+
+tests :: IO [TS.Test]
+tests = return [ TS.Test (hunitTestInstance hunitTests) ]
diff --git a/tests/Util.hs b/tests/Util.hs
new file mode 100644
--- /dev/null
+++ b/tests/Util.hs
@@ -0,0 +1,25 @@
+module Util
+    ( hunitTestInstance
+    ) where
+
+import qualified Distribution.TestSuite as TS
+import qualified Test.HUnit             as HU
+
+runHUnitTests :: HU.Test -> IO TS.Progress
+runHUnitTests tests = do
+   (HU.Counts cases tried errors failures) <- HU.runTestTT tests
+   return $ if errors > 0
+      then TS.Finished $ TS.Error "There were errors in the HUnit tests"
+      else if failures > 0
+         then TS.Finished $ TS.Fail "There were failures in the HUnit tests"
+         else TS.Finished TS.Pass
+
+hunitTestInstance :: HU.Test -> TS.TestInstance
+hunitTestInstance hTests = TS.TestInstance
+    { TS.run = runHUnitTests hTests
+    , TS.name = "HUnit Tests"
+    , TS.tags = ["hunit"]
+    , TS.options = []
+    , TS.setOption = \_ _ -> Right (hunitTestInstance hTests)
+    }
+
diff --git a/tests/ValueExtractors.hs b/tests/ValueExtractors.hs
new file mode 100644
--- /dev/null
+++ b/tests/ValueExtractors.hs
@@ -0,0 +1,45 @@
+module ValueExtractors where
+
+import           Data.Aeson
+import qualified Data.HashMap.Strict as M
+import qualified Data.Scientific     as S
+import qualified Data.Text           as T
+import qualified Data.Vector         as V
+
+get :: T.Text -> Value -> IO Value
+get key (Object o) = maybe notFound return $ M.lookup key o
+    where
+        notFound = fail $ "Could not find a value in object with key: " ++ T.unpack key
+get key _ = fail $ "The value was not an object when looking for: " ++ T.unpack key
+
+getArray :: Value -> IO [Value]
+getArray (Array v) = return . V.toList $ v
+getArray v = fail $ "Value is not an array. It is a: " ++ valueAsString v
+
+getString :: Value -> IO T.Text
+getString (String s) = return s
+getString v = fail $ "Value is not a string value. It is a: " ++ valueAsString v
+
+getBool :: Value -> IO Bool
+getBool (Bool b) = return b
+getBool v = fail $ "Value is not a boolean. It is a: " ++ valueAsString v
+
+isObject :: Value -> Bool
+isObject (Object _) = True
+isObject _ = False
+
+isArray :: Value -> Bool
+isArray (Array _) = True
+isArray _ = False
+
+getNumber :: Value -> IO S.Scientific
+getNumber (Number n) = return n
+getNumber v = fail $ "Value is not a number. It is a: " ++ valueAsString v
+
+valueAsString :: Value -> String
+valueAsString (Object _) = "Object"
+valueAsString (Array _) = "Array"
+valueAsString (Bool _) = "Bool"
+valueAsString (String _) = "String"
+valueAsString (Number _) = "Number"
+valueAsString Null = "Null"
