diff --git a/docs/manual/webapi/2.0/resources/graphql.rst b/docs/manual/webapi/2.0/resources/graphql.rst
new file mode 100644
index 0000000000000000000000000000000000000000..4ce7b2a88b5c2fc2cc4c220609e12e7080bdadb4
--- /dev/null
+++ b/docs/manual/webapi/2.0/resources/graphql.rst
@@ -0,0 +1,2 @@
+.. webapi-resource::
+   :classname: reviewboard.webapi.resources.graphql.GraphQLResource
\ No newline at end of file
diff --git a/docs/manual/webapi/2.0/resources/index.rst b/docs/manual/webapi/2.0/resources/index.rst
index f8156e3b6f893ad25116c2cf65d002393503e93d..2081f51940d449d0d61fdcff57566138bafee712 100644
--- a/docs/manual/webapi/2.0/resources/index.rst
+++ b/docs/manual/webapi/2.0/resources/index.rst
@@ -48,6 +48,14 @@ Extensions
    extension-list
    extension
 
+GraphQL
+=======
+
+.. toctree::
+   :maxdepth: 1
+
+   graphql
+
 
 Hosting Services
 ================
diff --git a/reviewboard/dependencies.py b/reviewboard/dependencies.py
index e40543bfec7e5c6c52c4a842c083e3506a529688..8d904108fc0371017bb57b5d187be5885c0cef29 100644
--- a/reviewboard/dependencies.py
+++ b/reviewboard/dependencies.py
@@ -71,6 +71,9 @@ package_dependencies = {
 
     # django-oauth-toolkit dependencies:
     'django-braces': '==1.13.0',
+
+    # python graphql implementation:
+    'py-gql': '==0.6.1',
 }
 
 #: Dependencies only specified during the packaging process.
diff --git a/reviewboard/webapi/resources/graphql.py b/reviewboard/webapi/resources/graphql.py
new file mode 100644
index 0000000000000000000000000000000000000000..390599d30c6cbc5a57c295ab8cd49ded33032d9f
--- /dev/null
+++ b/reviewboard/webapi/resources/graphql.py
@@ -0,0 +1,662 @@
+"""GraphQL endpoint, query resolvers, and schema definition.
+
+This module contains functions to build GraphQL schema from Review Board
+WebAPIResources, the query resolvers for the schema, and the
+:py:class:`GraphQLResource` that defines the endpoint.
+
+This consists of:
+
+* :py:func:`build_model_schema`
+* :py:func:`build_model_links`
+* :py:func:`resolve_users`
+* :py:func:`resolve_user`
+* :py:func:`resolve_review_requests`
+* :py:func:`resolve_review_request`
+* :py:class:`GraphQLResource`
+"""
+
+import json
+
+from django.contrib.auth.models import User
+from django.core.exceptions import PermissionDenied
+from django.db.models import Q
+from djblets.db.query import get_object_or_none
+from djblets.webapi.errors import (
+    DOES_NOT_EXIST, NOT_LOGGED_IN, GRAPHQL_VALIDATION_ERROR)
+from djblets.webapi.resources import get_resource_for_object
+from djblets.webapi.responses import WebAPIResponseError
+from py_gql import build_schema, graphql_blocking
+
+from reviewboard.reviews.models import ReviewRequest
+from reviewboard.webapi.base import WebAPIResource
+from reviewboard.webapi.resources.review_request import ReviewRequestResource
+from reviewboard.webapi.resources.user import UserResource
+
+
+def build_model_schema(resource):
+    """Return the schema for given the resource.
+
+    This function builds the schema of the resource by converting the
+    resource's fields into GraphQL types.
+
+    Args:
+        resource (WebAPIResource):
+        The WebAPIResource to build the GraphQL schema from.
+
+    Returns:
+        str:
+        A string, formatted in the GraphQL Schema Definition Language (SDL),
+        of the required fields to be exposed to the GraphQL API.
+    """
+    schema_parts = []
+    fields = resource.fields
+
+    for field, field_type in fields.items():
+        field_type = field_type['type'].name
+
+        if field_type == 'Integer':
+            schema_parts.append(f'{field}: Int')
+        elif field_type in ('Choice', 'Dictionary', 'ISO 8601 Date/Time'):
+            schema_parts.append(f'{field}: String')
+        elif field_type == 'List':
+            schema_parts.append(f'{field}: [String!]')
+        elif field_type == 'Resource List':
+            # TODO: Here is where we would likely want to define the schema as
+            # another nested GraphQL type. For e.g. target_users could be a
+            # [User!] field, so we could resolve all user information and
+            # return it to the caller.
+            schema_parts.append(f'{field}: [ResourceFieldType!]')
+        elif field_type == 'Resource':
+            # TODO: In the ReviewRequest object, the 'Resource' fields need to
+            # be serialized in the 'Links' objects. Ideally, this could be done
+            # here, but I wasn't able to find an elegant way to accomplish this
+            # so for now, these fields are manually added to the schema.
+            pass
+        else:
+            schema_parts.append(f'{field}: {field_type}')
+
+    return '\n\t'.join(schema_parts)
+
+
+def build_model_links(resource):
+    """Return the Links schema for given the resource.
+
+    This function builds the Links schema by converting the resource's
+    allowed_methods and item_child_resources into appropriately names
+    GraphQL Link types.
+
+    Args:
+        resource (WebAPIResource):
+        The WebAPIResource to build the GraphQL schema from.
+
+    Returns:
+        str:
+        A string, formatted in the GraphQL Schema Definition Language (SDL),
+        of the required links.
+    """
+    schema_parts = []
+    methods = resource.allowed_methods
+    children = resource.item_child_resources
+
+    for method in methods:
+        if method == 'PUT':
+            schema_parts.append('update: Link')
+        elif method == 'POST':
+            schema_parts.append('create: Link')
+        elif method == 'DELETE':
+            schema_parts.append('delete: Link')
+        else:
+            schema_parts.append('self: Link')
+
+    for c in children:
+        schema_parts.append(f'{c.link_name}: Link')
+
+    return '\n\t'.join(schema_parts)
+
+
+schema_fmt = """
+    type Query {{
+        review_requests(query_params: ReviewRequestQueryInput):
+        [ReviewRequest]!
+        review_request(id: Int!): ReviewRequest
+        users(query_params: UserQueryInput): [User]!
+        user(username: String!): User
+    }}
+
+    type UserLinks {{
+        {user_links_schema}
+    }}
+
+    type User {{
+        {user_schema}
+        links: UserLinks
+    }}
+
+    type ReviewRequestLinks {{
+        {review_request_links}
+        repository: ResourceFieldType
+        submitter: ResourceFieldType
+    }}
+
+    type ResourceFieldType {{
+        href: String
+        method: String
+        title: String
+    }}
+
+    type ReviewRequest {{
+        {review_request_schema}
+        links: ReviewRequestLinks
+    }}
+
+    type Link {{
+        href: String!
+        method: String!
+    }}
+
+    input UserQueryInput {{
+        page: Int
+        show_inactive: Int
+        letter: String
+        order: Order
+    }}
+
+    input ReviewRequestQueryInput {{
+        page: Int
+        branch: String
+        changenum: Int
+        commit_id: String
+        time_added_to: String
+        time_added_from: String
+        last_updated_to: String
+        last_updated_from: String
+        from_user: String
+        repository: Int
+        show_all_unpublished: Boolean
+        issue_dropped_count: Int
+        issue_dropped_count_lt: Int
+        issue_dropped_count_lte: Int
+        issue_dropped_count_gt: Int
+        issue_dropped_count_gte: Int
+        issue_open_count: Int
+        issue_open_count_lt: Int
+        issue_open_count_lte: Int
+        issue_open_count_gt: Int
+        issue_open_count_gte: Int
+        issue_resolved_count: Int
+        issue_resolved_count_lt: Int
+        issue_resolved_count_lte: Int
+        issue_resolved_count_gt: Int
+        issue_resolved_count_gte: Int
+        ship_it: Boolean
+        ship_it_count: Int
+        ship_it_count_lt: Int
+        ship_it_count_lte: Int
+        ship_it_count_gt: Int
+        ship_it_count_gte: Int
+        status: Status
+        to_groups: String
+        to_user_groups: String
+        to_users: String
+        to_users_directly: String
+    }}
+
+    enum Status {{
+        all
+        discarded
+        pending
+        submitted
+    }}
+
+    enum Order {{
+        ASC
+        DESC
+    }}
+"""
+
+schema = schema_fmt.format(user_links_schema=build_model_links(UserResource),
+                           user_schema=build_model_schema(UserResource),
+                           review_request_links=build_model_links(
+                               ReviewRequestResource),
+                           review_request_schema=build_model_schema(
+                               ReviewRequestResource))
+
+schema = build_schema(schema)
+
+
+@schema.resolver('Query.users')
+def resolve_users(
+    _root,
+    ctx,
+    _info,
+    query_params={
+        'page': 0,
+        'is_active': False,
+        'letter': None,
+        'order': 'ASC'
+    }):
+    """GraphQL resolver for a query for many user objects.
+
+    This function gets the User objects specified by the ``query_params``,
+    serializes them, and returns a list of the serialized objects.
+
+    Args:
+        _root (object):
+            The parent value of the object whose field is being resolved. For
+            a field on the root Query type, this is often not used (reference
+            the GraphQL/py-gql docs for more details).
+
+        ctx (request):
+            The Request object of the original POST request. Used for
+            serializing the object links.
+
+        _info (py_gql.execution.ResolveInfo):
+            A ``ResolveInfo`` object which carries GraphQL specific information
+            about the field being currently resolved. This should be rarely
+            used outside of custom directives handling and query
+            optimizations such as collapsing requests or join optimization
+            (reference the GraphQL/py-gql docs for more details).
+
+        query_params (dict):
+            A dictionary of query parameters for the user search.
+
+            {
+                'page': ``Int``,
+                'is_active': ``Int``,
+                'letter': ``String`` || ``None``,
+                'order': 'ASC' || 'DESC'
+            }
+
+    Returns:
+        list:
+        A list of serialized User objects that satisfies the given
+        ``query_params``.
+    """
+    request = ctx.get('request', None)
+
+    page_size = 50
+    page = query_params.get('page', 0)
+    show_inactive = query_params.get('show_inactive', 0)
+    letter = query_params.get('letter', None)
+    order = query_params.get('order', 'ASC')
+
+    query = User.objects.all()
+
+    if show_inactive == 0:
+        query = query.filter(is_active=True)
+    if letter is not None:
+        query = query.filter(username__startswith=letter)
+    if order == 'ASC':
+        query = query.order_by('username')
+    elif order == 'DESC':
+        query = query.order_by('-username')
+
+    query = query[page_size * page:page_size]
+    serializer = get_resource_for_object(query.first())
+
+    data = [
+        serializer.serialize_object(user, request)
+        for user in query
+    ]
+
+    return data
+
+
+@schema.resolver('Query.user')
+def resolve_user(_root, ctx, _info, username):
+    """GraphQL resolver for a query for a single user object.
+
+    This function gets the User object specified by the username argument,
+    serializes it, and returns the dictionary of the serialized object. If the
+    user is not found, it returns ``None``.
+
+    Args:
+        _root (object):
+            The parent value of the object whose field is being resolved. For
+            a field on the root Query type, this is often not used (reference
+            the GraphQL/py-gql docs for more details).
+
+        ctx (request):
+            The Request object of the original POST request. Used for
+            serializing the object links.
+
+        _info (py_gql.execution.ResolveInfo):
+            A ``ResolveInfo`` object which carries GraphQL specific information
+            about the field being currently resolved. This should be rarely
+            used outside of custom directives handling and query
+            optimizations such as collapsing requests or join optimization
+            (reference the GraphQL/py-gql docs for more details).
+
+        username (string):
+            The username of the user being queried.
+
+    Returns:
+        dict:
+        The serialized user object that matches the provided search key
+        (username), or ``None`` if the user was not found.
+    """
+    request = ctx.get('request', None)
+
+    obj = get_object_or_none(User, username=username)
+
+    if obj is None:
+        return obj
+
+    serializer = get_resource_for_object(obj)
+    serializer.get_object
+    return serializer.serialize_object(obj, request)
+
+
+@schema.resolver('Query.review_requests')
+def resolve_review_requests(_root, ctx, _info, query_params={}):
+    """GraphQL resolver for a query for many review request objects.
+
+    This function gets the ReviewRequest objects specified by the
+    ``query_params``, serializes them, and returns a list of the serialized
+    objects.
+
+    Args:
+        _root (object):
+            The parent value of the object whose field is being resolved. For
+            a field on the root Query type, this is often not used (reference
+            the GraphQL/py-gql docs for more details).
+
+        ctx (request):
+            The Request object of the original POST request. Used for
+            serializing the object links.
+
+        _info (py_gql.execution.ResolveInfo):
+            A ``ResolveInfo`` object which carries GraphQL specific information
+            about the field being currently resolved. This should be rarely
+            used outside of custom directives handling and query
+            optimizations such as collapsing requests or join optimization
+            (reference the GraphQL/py-gql docs for more details).
+
+        query_params (dict):
+            A dictionary of query parameters for the user search.
+
+            {
+                'page': ``Int``,
+                'show_closed': ``Int``,
+                'order': 'ASC' || 'DESC',
+                'orderBy': ``String``,
+            }
+
+    Returns:
+        list:
+        A list of serialized User objects that satisfies the given
+        ``query_params``.
+
+    """
+    request = ctx.get('request', None)
+    local_site = ctx.get('local_site', None)
+    self = ctx.get('self', None)
+
+    page_size = 50
+    page = query_params.get('page', 0)
+
+    # TODO: This block of query filtering is borrowed from the Review Request
+    # resource (review_request.py) so both the REST and GraphQL endpoints can
+    # filter/return the same results. It would be best to DRY this out, and
+    # write a shared method to build the query based on either the request
+    # query params, or the graphql input params.
+
+    q = Q()
+
+    if 'to_groups' in query_params:
+        for group_name in query_params.get('to_groups').split(','):
+            q = q & ReviewRequest.objects.get_to_group_query(group_name,
+                                                             local_site)
+
+    if 'to_users' in query_params:
+        for username in query_params.get('to_users').split(','):
+            q = q & ReviewRequest.objects.get_to_user_query(username)
+
+    if 'to_users_directly' in query_params:
+        to_users_directly = \
+            query_params.get('to_users_directly').split(',')
+
+        for username in to_users_directly:
+            q = q & ReviewRequest.objects.get_to_user_directly_query(
+                username)
+
+    if 'to_users_groups' in query_params:
+        for username in query_params.get('to_users_groups').split(','):
+            q = q & ReviewRequest.objects.get_to_user_groups_query(
+                username)
+
+    if 'from_user' in query_params:
+        q = q & ReviewRequest.objects.get_from_user_query(
+            query_params.get('from_user'))
+
+    if 'repository' in query_params:
+        q = q & Q(repository=int(query_params.get('repository')))
+
+    commit_q = Q()
+
+    if 'changenum' in query_params:
+        try:
+            commit_q = Q(changenum=int(query_params.get('changenum')))
+        except (TypeError, ValueError):
+            pass
+
+    commit_id = query_params.get('commit_id', None)
+
+    if commit_id is not None:
+        commit_q = commit_q | Q(commit_id=commit_id)
+
+    if commit_q:
+        q = q & commit_q
+
+    if 'branch' in query_params:
+        q &= Q(branch=query_params['branch'])
+
+    if 'ship_it' in query_params:
+        ship_it = query_params.get('ship_it')
+
+        if ship_it in ('1', 'true', 'True'):
+            q = q & Q(shipit_count__gt=0)
+        elif ship_it in ('0', 'false', 'False'):
+            q = q & Q(shipit_count=0)
+
+    q = q & self.build_graphql_queries_for_int_field(
+        query_params, 'shipit_count', 'ship_it_count')
+
+    for issue_field in ('issue_open_count',
+                        'issue_dropped_count',
+                        'issue_resolved_count',
+                        'issue_verifying_count'):
+        q = q & self.build_graphql_queries_for_int_field(
+            request, issue_field)
+
+    if 'time_added_from' in query_params:
+        q = q & Q(time_added__gte=query_params['time_added_from'])
+
+    if 'time_added_to' in query_params:
+        q = q & Q(time_added__lt=query_params['time_added_to'])
+
+    if 'last_updated_from' in query_params:
+        q = q & Q(last_updated__gte=query_params['last_updated_from'])
+
+    if 'last_updated_to' in query_params:
+        q = q & Q(last_updated__lt=query_params['last_updated_to'])
+
+    status = ReviewRequest.string_to_status(
+        query_params.get('status', 'pending'))
+
+    can_submit_as = request.user.has_perm(
+        'reviews.can_submit_as_another_user', local_site)
+
+    request_unpublished = query_params.get('show_all_unpublished', '0')
+
+    if request_unpublished in ('0', 'false', 'False'):
+        request_unpublished = False
+    else:
+        request_unpublished = True
+
+    show_all_unpublished = (request_unpublished and
+                            (can_submit_as or
+                                request.user.is_superuser))
+
+    query = ReviewRequestResource.model.objects.public(
+        user=request.user,
+        status=status,
+        extra_query=q,
+        show_all_unpublished=show_all_unpublished)
+
+    query = query[page_size * page:page_size]
+    serializer = get_resource_for_object(query.first())
+
+    data = [
+        serializer.serialize_object(review_request, request)
+        for review_request in query
+    ]
+
+    return data
+
+
+@schema.resolver('Query.review_request')
+def resolve_review_request(root, ctx, info, id):
+    """GraphQL resolver for a query for a single review request object.
+
+    This function gets the Review Request object specified by the id argument,
+    serializes it, and returns the dictionary of the serialized object. If the
+    review request is not found, it returns ``None``.
+
+    Args:
+        _root (object):
+            The parent value of the object whose field is being resolved. For
+            a field on the root Query type, this is often not used (reference
+            the GraphQL/py-gql docs for more details).
+
+        ctx (request):
+            The Request object of the original POST request. Used for
+            serializing the object links.
+
+        _info (py_gql.execution.ResolveInfo):
+            A ``ResolveInfo`` object which carries GraphQL specific information
+            about the field being currently resolved. This should be rarely
+            used outside of custom directives handling and query
+            optimizations such as collapsing requests or join optimization
+            (reference the GraphQL/py-gql docs for more details).
+
+        id (int):
+            The id of the review request being queried.
+
+    Returns:
+        dict:
+        The serialized review request object that matches the provided search
+        key (id), or ``None`` if the review request was not found.
+
+    Raises:
+        PermissionDenied:
+            Raises if the user does not have permission to view the review
+            request.
+    """
+    request = ctx.get('request', None)
+
+    obj = get_object_or_none(ReviewRequest, pk=id)
+
+    if obj is None:
+        return obj
+
+    # Check if user has permission to view this review request (i.e. review
+    # request is public, or user has reviews.can_edit_reviewrequest permission
+    # set). If not, raise PermissionDenied so we can return NOT_LOGGED_IN.
+    if not obj.public and not request.user.has_perm('can_edit_reviewrequest'):
+        raise PermissionDenied
+
+    serializer = get_resource_for_object(obj)
+    serializer.get_object
+    return serializer.serialize_object(obj, request)
+
+
+class GraphQLResource(WebAPIResource):
+    """Endpoint for handling GraphQL requests.
+
+    Accepts POST requests with the request body containing the GraphQL query
+    with fields defined in the schema, e.g.
+
+    query {
+        users {
+            id
+            is_active
+            username
+            url
+        }
+    }
+
+    The query is validated against the GraphQL schema and calls the specified
+    resolver, if valid.
+
+    Currently, resolvers are defined for User and Review Request queries only.
+    """
+
+    name = 'graphql'
+    singleton = True
+    allowed_methods = ('POST',)
+
+    def create(self, request, local_site_name=None, *args, **query_params):
+        """Handle a GraphQL request.
+
+        This accepts and validates a GraphQL query from the POST request body.
+        If the query is valid, it resolves the query and returns the requested
+        data. If the query is malformed or invalid with the current schema, it
+        returns ``djblets.webapi.errors.GRAPHQL_VALIDATION_ERROR``.
+
+        Args:
+            request (django.http.HttpRequest):
+                The current HTTP request.
+
+            local_site_name (unicode, optional):
+                The Local Site name, if any.
+
+        Raises:
+            djblets.webapi.errors.GRAPHQL_VALIDATION_ERROR:
+                Raises if the GraphQL query is malformed or is invalid.
+
+            PermissionDenied:
+                Raises if the user does not have permission to view requested
+                data.
+        """
+
+        # The original request is needed to serialize the urls, and the
+        # local_site name is needed for some queries, so we build a
+        # context object here, and pass that to the resolvers.
+        local_site = self._get_local_site(local_site_name)
+        context = {'request': request, 'local_site': local_site, 'self': self}
+
+        data = json.loads(request.body)
+
+        try:
+            result = graphql_blocking(
+                schema,
+                data['query'],
+                variables=data.get('variables', {}),
+                context=context
+            )
+
+            if len(result.errors) == 0:  # no errors
+                rsp = result.response().get('data', {})
+                rsp_data = list(rsp.values())[0]
+
+                if rsp_data is not None:
+                    if isinstance(rsp_data, list):
+                        # if a list is returned, include a total count
+                        rsp['total_count'] = len(rsp_data)
+                        return 200, rsp
+                    return 200, rsp
+                else:
+                    return DOES_NOT_EXIST
+            else:
+                return WebAPIResponseError(
+                    request,
+                    err=GRAPHQL_VALIDATION_ERROR,
+                    extra_params=result.response(),
+                    mimetype=self._build_error_mimetype(request)
+                )
+        except PermissionDenied:
+            return NOT_LOGGED_IN
+
+
+graphql_resource = GraphQLResource()
diff --git a/reviewboard/webapi/resources/root.py b/reviewboard/webapi/resources/root.py
index 8623907e0abd6d60de04559f69046a2a7733957d..13e0c9af3756e7f09f85a7faa7e5306f29d463ab 100644
--- a/reviewboard/webapi/resources/root.py
+++ b/reviewboard/webapi/resources/root.py
@@ -26,6 +26,7 @@ class RootResource(WebAPIResource, DjbletsRootResource):
         super(RootResource, self).__init__([
             resources.default_reviewer,
             resources.extension,
+            resources.graphql,
             resources.hosting_service,
             resources.hosting_service_account,
             resources.oauth_app,
diff --git a/reviewboard/webapi/tests/mimetypes.py b/reviewboard/webapi/tests/mimetypes.py
index 915ee4947429b102bb6710ec57b0678f0f2bfd3d..632d31c5e75322a4d41fc2fdc3b4fb375834a5c3 100644
--- a/reviewboard/webapi/tests/mimetypes.py
+++ b/reviewboard/webapi/tests/mimetypes.py
@@ -64,6 +64,7 @@ filediff_comment_item_mimetype = _build_mimetype('file-diff-comment')
 general_comment_list_mimetype = _build_mimetype('general-comments')
 general_comment_item_mimetype = _build_mimetype('general-comment')
 
+graphql_item_mimetype = _build_mimetype('graphql')
 
 hosting_service_list_mimetype = _build_mimetype('hosting-services')
 hosting_service_item_mimetype = _build_mimetype('hosting-service')
diff --git a/reviewboard/webapi/tests/test_graphql.py b/reviewboard/webapi/tests/test_graphql.py
new file mode 100644
index 0000000000000000000000000000000000000000..d12cffc401903e6a8a80906a0963f7324959b806
--- /dev/null
+++ b/reviewboard/webapi/tests/test_graphql.py
@@ -0,0 +1,804 @@
+"""Unit tests for the GraphQL endpoint and query resolvers.
+
+This module contains unit tests for the GraphQL endpoint and query resolvers.
+
+This consists of:
+
+* :py:class:`ResourceGraphQlErrorTest`
+* :py:class:`ResourceUserResolverTest`
+* :py:class:`ResourceUsersResolverTest`
+* :py:class:`ResourceReviewRequestResolverTest`
+* :py:class:`ResourceReviewRequestsResolverTest`
+"""
+
+import json
+
+from django.contrib.auth.models import User
+from djblets.webapi.errors import (DOES_NOT_EXIST,
+                                   GRAPHQL_VALIDATION_ERROR,
+                                   NOT_LOGGED_IN)
+from djblets.webapi.testing.decorators import webapi_test_template
+
+from reviewboard.reviews.models import ReviewRequest
+from reviewboard.webapi.resources import resources
+from reviewboard.webapi.tests.base import BaseWebAPITestCase
+from reviewboard.webapi.tests.mimetypes import (graphql_item_mimetype)
+from reviewboard.webapi.tests.mixins import BasicTestsMetaclass
+from reviewboard.webapi.tests.urls import (get_graphql_url)
+
+
+class ResourceGraphQlErrorTest(BaseWebAPITestCase,
+                               metaclass=BasicTestsMetaclass):
+    """Testing that the GraphQL endpoint handles errors properly"""
+    fixtures = ['test_users']
+    sample_api_url = 'graphql/'
+    resource = resources.graphql
+
+    test_http_methods = ('POST',)
+
+    def test_get_undefined_schema_property(self):
+        """Testing the <URL> API for an undefined schema property (validation
+        error)"""
+        username = 'dopey'
+        data = {
+            'query': """query($username: String!) {
+                    user(username: $username) {
+                        id
+                        username
+                        is_active
+                        undefined
+                        }
+                    }""",
+            'variables': {'username': f'{username}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=400,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'fail')
+        self.assertEqual(rsp['err']['code'], GRAPHQL_VALIDATION_ERROR.code)
+        self.assertEqual(rsp['errors'][0]['message'],
+                         'Cannot query field \"undefined\" on type \"User\".')
+
+    def test_get_incorrect_schema(self):
+        """Testing the graphql query with a malformed query (syntax error)."""
+        username = 'dopey'
+        data = {
+            'query': """query($username: String!) {
+                    user(username: $username) {
+                        id
+                        username
+                        is_active
+                    }""",
+            'variables': {'username': f'{username}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=400,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'fail')
+        self.assertEqual(rsp['err']['code'], GRAPHQL_VALIDATION_ERROR.code)
+
+
+class ResourceUserResolverTest(BaseWebAPITestCase,
+                               metaclass=BasicTestsMetaclass):
+    """Testing the GraphQL endpoint User resolver"""
+    fixtures = ['test_users']
+    sample_api_url = 'graphql/'
+    resource = resources.graphql
+
+    test_http_methods = ('POST',)
+
+    @webapi_test_template
+    def test_get_user(self):
+        """Testing the <URL> API for a specific user and url serialization"""
+        username = 'dopey'
+        user = User.objects.get(username=username)
+
+        data = {
+            'query': """query($username: String!) {
+                    user(username: $username) {
+                        id
+                        username
+                        is_active
+                        first_name
+                        last_name
+                        email
+                        links {
+                            self {
+                                href
+                                method
+                                }
+                            }
+                        }
+                    }""",
+            'variables': {'username': f'{username}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=7,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['user']['id'], user.id)
+        self.assertEqual(rsp['user']['username'], user.username)
+        self.assertEqual(rsp['user']['is_active'], True)
+        self.assertEqual(rsp['user']['first_name'], user.first_name)
+        self.assertEqual(rsp['user']['last_name'], user.last_name)
+        self.assertEqual(rsp['user']['email'], user.email)
+        self.assertEqual(
+            rsp['user']['links'],
+            {
+                'self': {
+                    'href': 'http://testserver/api/users/dopey/',
+                    'method': 'GET',
+                },
+            })
+
+    @webapi_test_template
+    def test_get_private_user(self):
+        """Testing the <URL> API for a specific user with a private
+        profile."""
+        username = 'dopey'
+        user = User.objects.get(username=username)
+        profile = user.get_profile()
+        profile.is_private = True
+        profile.save(update_fields=('is_private',))
+
+        data = {
+            'query': """query($username: String!) {
+                    user(username: $username) {
+                        id
+                        username
+                        is_active
+                        first_name
+                        last_name
+                        email
+                    }
+                }""",
+            'variables': {'username': f'{username}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=5,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['user']['id'], user.id)
+        self.assertEqual(rsp['user']['username'], user.username)
+        self.assertEqual(rsp['user']['is_active'], True)
+        self.assertEqual(rsp['user']['first_name'], None)
+        self.assertEqual(rsp['user']['last_name'], None)
+        self.assertEqual(rsp['user']['email'], None)
+
+    @webapi_test_template
+    def test_get_user_not_found(self):
+        """Testing the <URL> API for a username not found"""
+        username = 'bad-username'
+
+        data = {
+            'query': """query($username: String!) {
+                    user(username: $username) {
+                        id
+                        username
+                    }
+                }""",
+            'variables': {'username': f'{username}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=404,
+                            expected_json=False,
+                            content_type='application/json')
+        rsp = json.loads(rsp)
+        self.assertEqual(rsp['stat'], 'fail')
+        self.assertEqual(rsp['err']['code'], DOES_NOT_EXIST.code)
+        self.assertEqual(rsp['err']['msg'], DOES_NOT_EXIST.msg)
+
+
+class ResourceUsersResolverTest(BaseWebAPITestCase,
+                                metaclass=BasicTestsMetaclass):
+    """Testing the GraphL endpoint Users resolver"""
+
+    fixtures = ['test_users']
+    sample_api_url = 'graphql/'
+    resource = resources.graphql
+
+    test_http_methods = ('POST',)
+
+    @webapi_test_template
+    def test_get_active_users(self):
+        """Testing the <URL> API for active users."""
+        # make dopey inactive
+        dopey = User.objects.get(username='dopey')
+        dopey.is_active = False
+        dopey.save()
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'show_inactive': 0
+            }
+        }
+
+        data = {
+            'query': """query($query_params: UserQueryInput) {
+                    users(query_params: $query_params) {
+                        id
+                        username
+                        is_active
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=7,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 3)
+        # assert all returned users have is_active = True
+        users_active = [u['is_active'] for u in rsp['users']]
+        self.assertEqual(all(users_active), True)
+
+    @webapi_test_template
+    def test_get_inactive_users(self):
+        """Testing the <URL> API for active and inactive users."""
+        # make dopey inactive
+        dopey = User.objects.get(username='dopey')
+        dopey.is_active = False
+        dopey.save()
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'show_inactive': 1
+            }
+        }
+
+        data = {
+            'query': """query($query_params: UserQueryInput) {
+                    users(query_params: $query_params) {
+                        id
+                        username
+                        is_active
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=11,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 4)
+        # assert dopey (inactive) is returned
+        usernames = [u['username'] for u in rsp['users']]
+        self.assertEqual('dopey' in usernames, True)
+
+    @webapi_test_template
+    def test_get_users_by_letter(self):
+        """Testing the <URL> API for filtering users by letter."""
+        variables = {
+            'query_params': {
+                'page': 0,
+                'letter': 'd'
+            }
+        }
+
+        data = {
+            'query': """query($query_params: UserQueryInput) {
+                    users(query_params: $query_params) {
+                        id
+                        username
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=9,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        # assert only dopey and doc are returned
+        self.assertEqual(rsp['total_count'], 2)
+
+    @webapi_test_template
+    def test_user_sort(self):
+        """Testing the <URL> API for sorting users."""
+        variables = {
+            'query_params': {
+                'page': 0,
+                'order': 'DESC'
+            }
+        }
+
+        data = {
+            'query': """query($query_params: UserQueryInput) {
+                    users(query_params: $query_params) {
+                        id
+                        username
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            # expected_num_queries=9,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 4)
+        # assert users are sorted in descending alphabetical
+        usernames = [u['username'] for u in rsp['users']]
+        expected_usernames = sorted(usernames, reverse=True)
+        self.assertEqual(usernames, expected_usernames)
+
+
+class ResourceReviewRequestResolverTest(BaseWebAPITestCase,
+                                        metaclass=BasicTestsMetaclass):
+    """Testing the GraphQL endpoint ReviewRequest resolver."""
+    fixtures = ['test_users']
+    sample_api_url = 'graphql/'
+    resource = resources.graphql
+
+    test_http_methods = ('POST',)
+
+    @webapi_test_template
+    def test_get_review_request(self):
+        """Testing the <URL> API for a specific review request."""
+        review_request = self.create_review_request(publish=True)
+
+        data = {
+            'query': """query($id: Int!) {
+                    review_request(id: $id) {
+                        id
+                        status
+                        summary
+                        public
+                    }
+                }""",
+            'variables': {'id': f'{review_request.id}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=10,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['review_request']['id'], review_request.id)
+        self.assertEqual(rsp['review_request']['public'],
+                         review_request.public)
+        self.assertEqual(rsp['review_request']['status'],
+                         ReviewRequest.status_to_string(review_request.status))
+        self.assertEqual(rsp['review_request']['summary'],
+                         review_request.summary)
+
+    @webapi_test_template
+    def test_get_private_review_request_no_permission(self):
+        """Testing the <URL> API for a private review request with the
+        client not logged in."""
+
+        review_request = self.create_review_request(public=False)
+
+        data = {
+            'query': """query($id: Int!) {
+                    review_request(id: $id) {
+                        id
+                        status
+                        summary
+                        public
+                    }
+                }""",
+            'variables': {'id': f'{review_request.id}'}
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=401,
+                            expected_num_queries=5,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'fail')
+        self.assertEqual(rsp['err']['code'], NOT_LOGGED_IN.code)
+        self.assertEqual(rsp['err']['msg'], NOT_LOGGED_IN.msg)
+
+    @webapi_test_template
+    def test_get_private_review_request(self):
+        """Testing the <URL> API for a private review request with the
+        client logged in."""
+
+        review_request = self.create_review_request(public=False)
+        self._login_user(admin=True)
+
+        data = {
+            'query': """query($id: Int!) {
+                    review_request(id: $id) {
+                        id
+                        status
+                        summary
+                        public
+                    }
+                }""",
+            'variables': {'id': f'{review_request.id}'}
+        }
+
+        self.api_post(get_graphql_url(), json.dumps(data),
+                      expected_status=200,
+                      expected_mimetype=graphql_item_mimetype,
+                      expected_num_queries=10,
+                      content_type='application/json')
+
+    @webapi_test_template
+    def test_get_review_request_not_found(self):
+        """Testing the <URL> API for an invalid review request id."""
+        id = 99
+        data = {
+            'query': """query($id: Int!) {
+                    review_request(id: $id) {
+                        id
+                        status
+                        summary
+                        public
+                    }
+                }""",
+            'variables': {'id': f'{id}'}
+        }
+
+        self.api_post(get_graphql_url(), json.dumps(data),
+                      expected_status=404,
+                      expected_json=False,
+                      content_type='application/json')
+
+
+class ResourceReviewRequestsResolverTest(BaseWebAPITestCase,
+                                         metaclass=BasicTestsMetaclass):
+    """Testing the graphql ReviewRequests resolver"""
+    fixtures = ['test_users']
+    sample_api_url = 'graphql/'
+    resource = resources.graphql
+
+    test_http_methods = ('POST',)
+
+    @webapi_test_template
+    def test_get_review_requests(self):
+        """Testing the <URL> API for published review requests."""
+        review_request_1 = self.create_review_request(publish=True)
+        review_request_2 = self.create_review_request(publish=True)
+        self.create_review_request(publish=False)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=22,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 2)
+        self.assertEqual(rsp['review_requests'][0]['id'], review_request_2.id)
+        self.assertEqual(rsp['review_requests'][0]
+                         ['public'], review_request_2.public)
+        self.assertEqual(rsp['review_requests'][1]['id'], review_request_1.id)
+        self.assertEqual(rsp['review_requests'][1]
+                         ['public'], review_request_1.public)
+
+    @webapi_test_template
+    def test_get_review_request_show_unpublished_no_permissions(self):
+        """Testing the <URL> API for unpublished review requests, without
+        the required user permissions."""
+
+        self.create_review_request(publish=True)
+        self.create_review_request(publish=False)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'show_all_unpublished': True
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=15,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        # Client does not have permissions, only 1 request should be returned
+        self.assertEqual(rsp['total_count'], 1)
+
+    @webapi_test_template
+    def test_get_review_request_show_unpublished(self):
+        """Testing the <URL> API for unpublished review requests, with
+        the required user permissions."""
+
+        self.create_review_request(publish=True)
+        self.create_review_request(publish=False)
+        self._login_user(admin=True)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'show_all_unpublished': True
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            expected_num_queries=18,
+                            content_type='application/json')
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 2)
+
+    @webapi_test_template
+    def test_get_with_to_groups(self):
+        """Testing the <URL> API for unpublished review requests, filtering
+        by a review group."""
+        group = self.create_review_group(name='devgroup')
+
+        self.create_review_request(publish=True)
+
+        review_request = self.create_review_request(publish=True)
+        review_request.target_groups.add(group)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'to_groups': 'devgroup'
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                        review_requests(query_params: $query_params) {
+                            id
+                            public
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 1)
+
+    @webapi_test_template
+    def test_get_review_requests_to_users(self):
+        """Testing the <URL> API for review requests, filtered by
+        to_users."""
+        grumpy = User.objects.get(username='grumpy')
+
+        self.create_review_request(publish=True)
+
+        review_request = self.create_review_request(publish=True)
+        review_request.target_people.add(grumpy)
+
+        review_request = self.create_review_request(publish=True)
+        review_request.target_people.add(grumpy)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'to_users': 'grumpy'
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                        review_requests(query_params: $query_params) {
+                            id
+                            public
+                        }
+                    }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'], 2)
+
+    @webapi_test_template
+    def test_get_review_requests_filter_by_status(self):
+        """Testing the <URL> API for review requests, filtered by status."""
+
+        self.create_review_request(publish=True, status='S')
+        self.create_review_request(publish=True, status='S')
+        self.create_review_request(publish=True, status='D')
+        self.create_review_request(publish=True, status='P')
+        self.create_review_request(publish=True, status='P')
+        self.create_review_request(publish=True, status='P')
+        self.create_review_request(public=False, status='P')
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'status': 'submitted'
+            }
+        }
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['review_requests']), 2)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'status': 'pending'
+            }
+        }
+        data['variables'] = variables
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['review_requests']), 3)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'status': 'all'
+            }
+        }
+        data['variables'] = variables
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['review_requests']), 6)
+
+    @webapi_test_template
+    def test_get_review_requests_filter_by_ship_it_count(self):
+        """Testing the <URL> API for unpublished review requests, filtering
+        by ship it count."""
+        self.create_review_request(publish=True)
+
+        review_request = self.create_review_request(publish=True)
+        self.create_review(review_request, ship_it=True, publish=True)
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'ship_it_count': 1
+            }
+        }
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['review_requests']), 1)
+
+    @webapi_test_template
+    def test_get_review_requests_filter_by_time_added_from(self):
+        """Testing the <URL> API for unpublished review requests, filtering
+        by time added from."""
+        start_index = 3
+
+        public_review_requests = [
+            self.create_review_request(publish=True),
+            self.create_review_request(publish=True),
+            self.create_review_request(publish=True),
+            self.create_review_request(publish=True),
+            self.create_review_request(publish=True),
+        ]
+
+        r = public_review_requests[start_index]
+        timestamp = r.time_added.isoformat()
+
+        variables = {
+            'query_params': {
+                'page': 0,
+                'time_added_from': timestamp
+            }
+        }
+
+        data = {
+            'query': """query($query_params: ReviewRequestQueryInput) {
+                    review_requests(query_params: $query_params) {
+                        id
+                        public
+                    }
+                }""",
+            'variables': variables
+        }
+
+        rsp = self.api_post(get_graphql_url(), json.dumps(data),
+                            expected_status=200,
+                            expected_mimetype=graphql_item_mimetype,
+                            content_type='application/json')
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(rsp['total_count'],
+                         len(public_review_requests) - start_index)
+        self.assertEqual(
+            rsp['total_count'],
+            ReviewRequest.objects.filter(
+                public=True, status='P',
+                time_added__gte=r.time_added).count())
diff --git a/reviewboard/webapi/tests/urls.py b/reviewboard/webapi/tests/urls.py
index 8bec27c4d826037fdff6bee8cdc2a6f5ebb3e642..33ad863c67b894ba52553ead33fe1be4034e37e4 100644
--- a/reviewboard/webapi/tests/urls.py
+++ b/reviewboard/webapi/tests/urls.py
@@ -335,6 +335,15 @@ def get_general_comment_item_url(review_request, comment_id,
         comment_id=comment_id)
 
 
+#
+# GraphQL
+#
+def get_graphql_url(local_site_name=None):
+    return resources.graphql.get_item_url(
+        local_site_name=local_site_name
+    )
+
+
 #
 # HostingServiceResource
 #
