Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@
RESTFULGIT_CORS_ALLOW_CREDENTIALS = False
RESTFULGIT_CORS_ALLOWED_HEADERS = []
RESTFULGIT_CORS_MAX_AGE = timedelta(days=30)

# Cache-Control header for conditional GETs
RESTFULGIT_CACHE_CONTROL = "max-age=3600"
92 changes: 84 additions & 8 deletions restfulgit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# coding=utf-8
from __future__ import print_function

import flask
from flask import Flask, url_for, request, Response, current_app, Blueprint, safe_join, send_from_directory, make_response
from werkzeug.exceptions import NotFound, BadRequest, HTTPException, default_exceptions
from werkzeug.exceptions import NotFound, BadRequest, HTTPException, PreconditionRequired, default_exceptions
from werkzeug.routing import BaseConverter
from werkzeug.wrappers import ETagResponseMixin

from pygit2 import (Repository,
GIT_OBJ_COMMIT, GIT_OBJ_TREE, GIT_OBJ_BLOB, GIT_OBJ_TAG,
Expand All @@ -16,6 +18,7 @@
import json
import os
import functools
import hashlib

# Optionally use better libmagic-based MIME-type guessing
try:
Expand All @@ -42,6 +45,7 @@ class DefaultConfig(object):
RESTFULGIT_CORS_ALLOW_CREDENTIALS = False
RESTFULGIT_CORS_MAX_AGE = timedelta(days=30)
RESTFULGIT_CORS_ALLOWED_ORIGIN = "*"
RESTFULGIT_CACHE_CONTROL = "max-age=3600"


app = Flask(__name__)
Expand Down Expand Up @@ -277,13 +281,60 @@ def _convert_blob(repo_key, blob):

def _convert_ref(repo_key, ref, obj):
return {
"url": url_for('.get_ref_list', _external=True,
"url": url_for('.get_refs', _external=True,
repo_key=repo_key, ref_path=ref.name[5:]), # [5:] to cut off the redundant refs/
"ref": ref.name,
"object": _linkobj_for_gitobj(repo_key, obj, include_type=True),
}


# Modified from the conditional-get snippet at:
# http://flask.pocoo.org/snippets/95/
class NotModified(HTTPException):
code = 304

def get_response(self, environ): # pylint: disable=W0222
return flask.Response(status=304)


def conditional(func):
'''
Requires the decorated function to return a response object
Decorated function must also define flask.g.etag
'''
@functools.wraps(func)
def wrapper(*args, **kwargs):
flask.g.condtnl_etags_start = True
response = func(*args, **kwargs)
if not hasattr(flask.g, 'etag'):
return response
response.set_etag(flask.g.etag)
response.headers['Cache-Control'] = current_app.config['RESTFULGIT_CACHE_CONTROL']
return response
return wrapper


_OLD_SET_ETAG = ETagResponseMixin.set_etag


@functools.wraps(ETagResponseMixin.set_etag)
def _new_set_etag(self, etag, weak=False):
if (hasattr(flask.g, 'condtnl_etags_start') and flask.g.condtnl_etags_start):
# Not sure if we'll use these, but completeness is nice
if flask.request.method in ('PUT', 'DELETE', 'PATCH'):
if not flask.request.if_match:
raise PreconditionRequired
if etag not in flask.request.if_match:
flask.abort(412)
elif (flask.request.method == 'GET' and
flask.request.if_none_match and
etag in flask.request.if_none_match):
raise NotModified
flask.g.condtnl_etags_start = False
_OLD_SET_ETAG(self, etag, weak)
ETagResponseMixin.set_etag = _new_set_etag


def jsonify(func):
def dthandler(obj):
if hasattr(obj, 'isoformat'):
Expand Down Expand Up @@ -379,6 +430,7 @@ class SHAConverter(BaseConverter): # pylint: disable=W0232


@restfulgit.route('/repos/<repo_key>/git/commits/')
@conditional
@corsify
@jsonify
def get_commit_list(repo_key):
Expand Down Expand Up @@ -413,32 +465,42 @@ def get_commit_list(repo_key):
raise NotFound("commit not found")

commits = [_convert_commit(repo_key, commit) for commit in islice(walker, limit)]
sha = hashlib.sha1()
for commit in islice(walker, limit):
sha.update(commit.hex)
flask.g.etag = sha.hexdigest()
return commits


@restfulgit.route('/repos/<repo_key>/git/commits/<sha:sha>/')
@conditional
@corsify
@jsonify
def get_commit(repo_key, sha):
flask.g.etag = sha
repo = _get_repo(repo_key)
commit = _get_commit(repo, sha)
return _convert_commit(repo_key, commit)


@restfulgit.route('/repos/<repo_key>/git/trees/<sha:sha>/')
@conditional
@corsify
@jsonify
def get_tree(repo_key, sha):
flask.g.etag = sha
recursive = request.args.get('recursive') == '1'
repo = _get_repo(repo_key)
tree = _get_tree(repo, sha)
return _convert_tree(repo_key, repo, tree, recursive)


@restfulgit.route('/repos/<repo_key>/git/blobs/<sha:sha>/')
@conditional
@corsify
@jsonify
def get_blob(repo_key, sha):
flask.g.etag = sha
repo = _get_repo(repo_key)
try:
blob = repo[unicode(sha)]
Expand All @@ -450,9 +512,11 @@ def get_blob(repo_key, sha):


@restfulgit.route('/repos/<repo_key>/git/tags/<sha:sha>/')
@conditional
@corsify
@jsonify
def get_tag(repo_key, sha):
flask.g.etag = sha
repo = _get_repo(repo_key)
tag = _get_tag(repo, sha)
return _convert_tag(repo_key, repo, tag)
Expand All @@ -477,10 +541,12 @@ def get_description(repo_key):
extant_relative_path = next(extant_relative_paths, None)
if extant_relative_path is None:
return Response("", mimetype=PLAIN_TEXT)
return send_from_directory(current_app.config['RESTFULGIT_REPO_BASE_PATH'], extant_relative_path, mimetype=PLAIN_TEXT)
return send_from_directory(current_app.config['RESTFULGIT_REPO_BASE_PATH'], extant_relative_path,
mimetype=PLAIN_TEXT, conditional=True)


@restfulgit.route('/repos/')
@conditional
@corsify
@jsonify
def get_repo_list():
Expand All @@ -493,14 +559,16 @@ def get_repo_list():
working_copies = set(name for name, full_path in subdirs if os.path.isdir(safe_join(full_path, '.git')))
repositories = list(mirrors | working_copies)
repositories.sort()
flask.g.etag = hashlib.sha1(''.join(repositories)).hexdigest()
return {'repos': repositories}


@restfulgit.route('/repos/<repo_key>/git/refs/')
@restfulgit.route('/repos/<repo_key>/git/refs/<path:ref_path>')
@conditional
@corsify
@jsonify
def get_ref_list(repo_key, ref_path=None):
def get_refs(repo_key, ref_path=None):
if ref_path is not None:
ref_path = "refs/" + ref_path
else:
Expand All @@ -513,24 +581,32 @@ def get_ref_list(repo_key, ref_path=None):
_convert_ref(repo_key, reference, repo[reference.target])
for reference in nonsymbolic_refs
]
if len(ref_data) == 1:
if len(ref_data) == 1 and ref_data[0]['ref'] == ref_path:
# exact match
ref_data = ref_data[0]
flask.g.etag = ref_data['object']['sha']
else:
sha = hashlib.sha1()
for ref in ref_data:
sha.update(ref['ref'])
flask.g.etag = sha.hexdigest()
return ref_data


@restfulgit.route('/repos/<repo_key>/blob/<branch_or_tag_or_sha>/<path:file_path>')
@conditional
@corsify
def get_raw(repo_key, branch_or_tag_or_sha, file_path):
repo = _get_repo(repo_key)
commit = _get_commit_for_refspec(repo, branch_or_tag_or_sha)
tree = _get_tree(repo, commit.tree.hex)
git_obj = _get_object_from_path(repo, tree, file_path)

if git_obj.type != GIT_OBJ_BLOB:
return "not a file", 406

raise NotFound("Path not a blob")
flask.g.etag = git_obj.hex
data = git_obj.data
mime_type = guess_mime_type(os.path.basename(file_path), data)

if mime_type is None:
mime_type = OCTET_STREAM
return Response(data, mimetype=mime_type)
Expand Down
26 changes: 24 additions & 2 deletions tests/test_restfulgit.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def test_get_tag_works(self):


class RefsTestCase(_RestfulGitTestCase):
def test_get_ref_list_works(self):
def test_get_refs_works(self):
resp = self.client.get('/repos/restfulgit/git/refs/')
self.assert200(resp)
ref_list = resp.json
Expand All @@ -434,7 +434,7 @@ def test_invalid_ref_path(self):
self.assert200(resp)
self.assertEqual([], resp.json)

def test_valid_ref_path(self):
def test_valid_specific_ref_path(self):
resp = self.client.get('/repos/restfulgit/git/refs/tags/initial')
self.assert200(resp)
self.assertEqual(
Expand Down Expand Up @@ -624,6 +624,28 @@ def test_allowed_methods(self):
with self.cors_enabled:
self.assert_header_equal('Access-Control-Allow-Methods', 'HEAD, OPTIONS, GET')

class ConditionalGetTestCase(_RestfulGitTestCase):
cache_control = 'max-age=60'
def cget_helper(self, url, guess=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably split this into 3 test methods, one per GET request.

with self.config_override('RESTFULGIT_CACHE_CONTROL', self.cache_control):
resp = self.client.get(url)
self.assert200(resp)
# Check that etag and cache-control headers are sent
self.assertEqual(resp.headers['Cache-Control'], self.cache_control)
self.assertIn('ETag', resp.headers)
self.etag = resp.headers['ETag']
if not guess is None:
self.assertEqual(resp.headers['ETag'], guess)
# Retry request, with If-None-Match and proper etag
resp = self.client.get(url, headers={'If-None-Match': self.etag})
self.assertStatus(resp, 304)
# Retry request, with If-None-Match and invalid etag
resp = self.client.get(url, headers={'If-None-Match': self.etag.swapcase()})
self.assert200(resp)

def test_generic_cget_decorator(self):
self.cget_helper('http://localhost/repos/restfulgit/git/tags/1dffc031c9beda43ff94c526cbc00a30d231c079/',
guess='"1dffc031c9beda43ff94c526cbc00a30d231c079"')

if __name__ == '__main__':
unittest.main()