Merge branch 'master' of ssh://camlistore.org:29418/camlistore

This commit is contained in:
Brad Fitzpatrick 2011-08-24 18:23:31 +04:00
commit a9853a4622
19 changed files with 628 additions and 25 deletions

View File

@ -638,7 +638,7 @@ TARGET: lib/go/camli/auth
TARGET: lib/go/camli/blobref
TARGET: lib/go/camli/blobserver
TARGET: lib/go/camli/blobserver/cond
TARGET: lib/go/camli/blobserver/googlestorage
TARGET: lib/go/camli/blobserver/google
TARGET: lib/go/camli/blobserver/handlers
TARGET: lib/go/camli/blobserver/localdisk
TARGET: lib/go/camli/blobserver/remote
@ -651,6 +651,8 @@ TARGET: lib/go/camli/db
TARGET: lib/go/camli/db/dbimpl
TARGET: lib/go/camli/errorutil
TARGET: lib/go/camli/fs
TARGET: lib/go/camli/googlestorage
=skip_tests
TARGET: lib/go/camli/httputil
TARGET: lib/go/camli/jsonconfig
TARGET: lib/go/camli/jsonsign

View File

@ -23,7 +23,7 @@ import (
"os"
"strings"
"camli/blobserver/googlestorage"
"camli/blobserver/google"
"camli/third_party/code.google.com/goauth2/oauth"
)
@ -37,7 +37,7 @@ func main() {
if clientId, clientSecret, err = getClientInfo(); err != nil {
panic(err)
}
transport := googlestorage.MakeOauthTransport(clientId, clientSecret, "")
transport := google.MakeOauthTransport(clientId, clientSecret, "")
var accessCode string
if accessCode, err = getAccessCode(transport.Config); err != nil {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package googlestorage
package google
import (
"camli/third_party/code.google.com/goauth2/oauth"

View File

@ -0,0 +1,82 @@
/*
Copyright 2011 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package google
import (
"io"
"os"
"camli/blobref"
"camli/blobserver"
"camli/googlestorage"
"camli/jsonconfig"
)
type Storage struct {
hub *blobserver.SimpleBlobHub
bucket string // the gs bucket containing blobs
client *googlestorage.Client
}
func newFromConfig(_ blobserver.Loader, config jsonconfig.Obj) (blobserver.Storage, os.Error) {
auth := config.RequiredObject("auth")
gs := &Storage{
&blobserver.SimpleBlobHub{},
config.RequiredString("bucket"),
googlestorage.NewClient(MakeOauthTransport(
auth.RequiredString("client_id"),
auth.RequiredString("client_secret"),
auth.RequiredString("refresh_token"),
)),
}
if err := config.Validate(); err != nil {
return nil, err
}
if err := auth.Validate(); err != nil {
return nil, err
}
return gs, nil
}
func (gs *Storage) EnumerateBlobs(dest chan<- blobref.SizedBlobRef, after string, limit uint, waitSeconds int) os.Error {
// TODO: Implement stub
return nil
}
func (gs *Storage) ReceiveBlob(blob *blobref.BlobRef, source io.Reader) (blobref.SizedBlobRef, os.Error) {
// TODO: Implement stub
return blobref.SizedBlobRef{}, nil
}
func (gs *Storage) Stat(dest chan<- blobref.SizedBlobRef, blobs []*blobref.BlobRef, waitSeconds int) os.Error {
// TODO: Implement stub
return nil
}
func (gs *Storage) FetchStreaming(blob *blobref.BlobRef) (io.ReadCloser, int64, os.Error) {
// TODO: Implement stub
return nil, 0, nil
}
func (gs *Storage) Remove(blobs []*blobref.BlobRef) os.Error {
// TODO: Implement stub
return nil
}
func (gs *Storage) GetBlobHub() blobserver.BlobHub {
return gs.hub
}

View File

@ -0,0 +1,30 @@
Implements the Storage interface for Google Storage.
A GoogleStorage instance stores blobs in a single Google Storage bucket, with
each blob keyed by its blobref.
Testing:
gsapi_test.go contains integration tests which run against Google Storage.
In order to run these tests properly, you will need to:
1. Set up google storage. See:
http://code.google.com/apis/storage/docs/signup.html
2. Upload the contents of the testdata dir to a google storage bucket. Note
that all these files begin with 'test-': such files will be ignored when
the bucket is used as blobserver storage.
3. Create the config file '~/.camli/gstestconfig.json'. The file should look
something like this:
{
"auth": {
"client_id": "your client id",
"client_secret": "your client secret",
"refresh_token": "a refresh token"
},
"bucket": "bucket.example.com"
}
You can use camgsinit to help obtain the auth config object.

View File

@ -0,0 +1,240 @@
/*
Copyright 2011 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Implements the Google Storage API calls needed by camlistore.
// This is intended to be exclude camlistore-specific logic.
package googlestorage
import (
"fmt"
"http"
"io"
"log"
"os"
"strconv"
"strings"
"xml"
"camli/third_party/code.google.com/goauth2/oauth"
)
const (
gsAccessURL = "https://commondatastorage.googleapis.com"
)
type Client struct {
transport *oauth.Transport
client *http.Client
}
type Object struct {
Bucket string
Key string
}
type SizedObject struct {
Object
Size int64
}
func NewClient(transport *oauth.Transport) *Client {
return &Client{transport, transport.Client()}
}
func (gso Object) String() string {
return fmt.Sprintf("%v/%v", gso.Bucket, gso.Key)
}
func (sgso SizedObject) String() string {
return fmt.Sprintf("%v/%v (%vB)", sgso.Bucket, sgso.Key, sgso.Size)
}
// A close relative to http.Client.Do(), helping with token refresh logic.
// If canResend is true and the initial request's response is an auth error
// (401 or 403), oauth credentials will be refreshed and the request sent
// again. This should only be done for requests with empty bodies, since the
// Body will be consumed on the first attempt if it exists.
// If canResend is false, and req would have been resent if canResend were
// true, then shouldRetry will be true.
// One of resp or err will always be nil.
func (gsa *Client) doRequest(req *http.Request, canResend bool) (resp *http.Response, err os.Error, shouldRetry bool) {
if resp, err = gsa.client.Do(req); err != nil {
return
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
// Unauth. Perhaps tokens need refreshing?
if err = gsa.transport.Refresh(); err != nil {
return
}
// Refresh succeeded. req should be resent
if !canResend {
return resp, nil, true
}
// Resend req. First, need to close the soon-overwritten response Body
resp.Body.Close()
resp, err = gsa.client.Do(req)
}
return
}
// Makes a simple body-less google storage request
func (gsa *Client) simpleRequest(method, url string) (resp *http.Response, err os.Error) {
// Construct the request
req, err := http.NewRequest(method, url, nil)
if err != nil {
return
}
req.Header.Set("x-goog-api-version", "2")
resp, err, _ = gsa.doRequest(req, true)
return
}
// Fetch a GS object.
// Bucket and Key fields are trusted to be valid.
// Returns (object reader, object size, err). Reader must be closed.
func (gsa *Client) GetObject(obj *Object) (io.ReadCloser, int64, os.Error) {
log.Printf("Fetching object from Google Storage: %s/%s\n", obj.Bucket, obj.Key)
resp, err := gsa.simpleRequest("GET", gsAccessURL+"/"+obj.Bucket+"/"+obj.Key)
if err != nil {
return nil, 0, fmt.Errorf("GS GET request failed: %v\n", err)
}
if resp.StatusCode != http.StatusOK {
return nil, 0, fmt.Errorf("GS GET request failed status: %v\n", resp.Status)
}
return resp.Body, resp.ContentLength, nil
}
// Check for size / existence of a GS object.
// Bucket and Key fields are trusted to be valid.
// err signals io / authz errors, a nonexistant file is not an error.
func (gsa *Client) StatObject(obj *Object) (size int64, exists bool, err os.Error) {
log.Printf("Statting object in Google Storage: %s/%s\n", obj.Bucket, obj.Key)
resp, err := gsa.simpleRequest("HEAD", gsAccessURL+"/"+obj.Bucket+"/"+obj.Key)
if err != nil {
return
}
resp.Body.Close() // should be empty
if resp.StatusCode == http.StatusNotFound {
return
}
if resp.StatusCode == http.StatusOK {
if size, err = strconv.Atoi64(resp.Header["Content-Length"][0]); err != nil {
return
}
return size, true, nil
}
// Any response other than 404 or 200 is erroneous
return 0, false, fmt.Errorf("Bad head response code: %v", resp.Status)
}
// Upload a GS object. Bucket and Key are trusted to be valid.
// shouldRetry will be true if the put failed due to authorization, but
// credentials have been refreshed and another attempt is likely to succeed.
// In this case, content will have been consumed.
func (gsa *Client) PutObject(obj *Object, content io.ReadCloser) (shouldRetry bool, err os.Error) {
log.Printf("Putting object in Google Storage: %s/%s\n", obj.Bucket, obj.Key)
objURL := gsAccessURL + "/" + obj.Bucket + "/" + obj.Key
var req *http.Request
if req, err = http.NewRequest("PUT", objURL, content); err != nil {
return
}
req.Header.Set("x-goog-api-version", "2")
var resp *http.Response
if resp, err, shouldRetry = gsa.doRequest(req, false); err != nil {
return
}
resp.Body.Close() // should be empty
if resp.StatusCode != http.StatusOK {
return shouldRetry, fmt.Errorf("Bad put response code: %v", resp.Status)
}
return
}
// Removes a GS object.
// Bucket and Key values are trusted to be valid.
func (gsa *Client) DeleteObject(obj *Object) (err os.Error) {
log.Printf("Deleting %v/%v\n", obj.Bucket, obj.Key)
// bucketURL := gsAccessURL + "/" + obj.Bucket + "/" + obj.Key
resp, err := gsa.simpleRequest("DELETE", gsAccessURL+"/"+obj.Bucket+"/"+obj.Key)
if err != nil {
return
}
if resp.StatusCode != http.StatusNoContent {
err = fmt.Errorf("Bad delete response code: %v", resp.Status)
}
return
}
// Used for unmarshalling XML returned by enumerate request
type gsListResult struct {
Contents []SizedObject
}
// List the objects in a GS bucket.
// If after is nonempty, listing will begin with lexically greater object names
// If limit is nonzero, the length of the list will be limited to that number.
func (gsa *Client) EnumerateObjects(bucket, after string, limit uint) ([]SizedObject, os.Error) {
log.Printf("Fetching from %v: after '%v', limit %v\n", bucket, after, limit)
// Build url, with query params
params := make([]string, 0, 2)
if after != "" {
params = append(params, "marker="+http.URLEscape(after))
}
if limit > 0 {
params = append(params, fmt.Sprintf("max-keys=%v", limit))
}
query := ""
if len(params) > 0 {
query = "?" + strings.Join(params, "&")
}
// Make the request
resp, err := gsa.simpleRequest("GET", gsAccessURL+"/"+bucket+"/"+query)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Bad enumerate response code: %v", resp.Status)
}
// Parse the XML response
result := &gsListResult{make([]SizedObject, 0, limit)}
if err = xml.Unmarshal(resp.Body, result); err != nil {
return nil, err
}
// Fill in the Bucket on all the SizedObjects
for i, _ := range result.Contents {
result.Contents[i].Bucket = bucket
}
return result.Contents, nil
}

View File

@ -0,0 +1,220 @@
/*
Copyright 2011 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// FYI These tests are integration tests that need to run against google
// storage. See the README for more details on necessary setup
package googlestorage
import (
"bytes"
"fmt"
"testing"
"time"
"os"
"camli/jsonconfig"
"camli/third_party/code.google.com/goauth2/oauth"
)
const testObjectContent = "Google Storage Test\n"
type BufferCloser struct {
*bytes.Buffer
}
func (b *BufferCloser) Close() os.Error {
b.Reset()
return nil
}
// Reads google storage config and creates a Client. Exits on error.
func doConfig(t *testing.T) (gsa *Client, bucket string) {
cf, err := jsonconfig.ReadFile("testconfig.json")
if err != nil {
t.Fatalf("Failed to read config: %v", err)
}
var config jsonconfig.Obj
config = cf.RequiredObject("gsconf")
if err := cf.Validate(); err != nil {
t.Fatalf("Invalid config: %v", err)
}
auth := config.RequiredObject("auth")
bucket = config.RequiredString("bucket")
if err := config.Validate(); err != nil {
t.Fatalf("Invalid config: %v", err)
}
gsa = NewClient(&oauth.Transport{
&oauth.Config{
ClientId: auth.RequiredString("client_id"),
ClientSecret: auth.RequiredString("client_secret"),
Scope: "https://www.googleapis.com/auth/devstorage.read_write",
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://accounts.google.com/o/oauth2/token",
RedirectURL: "urn:ietf:wg:oauth:2.0:oob",
},
&oauth.Token{
AccessToken: "",
RefreshToken: auth.RequiredString("refresh_token"),
TokenExpiry: 0,
},
nil,
})
if err := auth.Validate(); err != nil {
t.Fatalf("Invalid config: %v", err)
}
return
}
func TestGetObject(t *testing.T) {
gs, bucket := doConfig(t)
body, size, err := gs.GetObject(&Object{bucket, "test-get"})
if err != nil {
t.Fatalf("Fetch failed: %v\n", err)
}
content := make([]byte, size)
if _, err = body.Read(content); err != nil {
t.Fatalf("Failed to read response body: %v:\n", err)
}
if string(content) != testObjectContent {
t.Fatalf("Object has incorrect content.\nExpected: '%v'\nFound: '%v'\n", testObjectContent, string(content))
}
}
func TestStatObject(t *testing.T) {
gs, bucket := doConfig(t)
// Stat a nonexistant file
size, exists, err := gs.StatObject(&Object{bucket, "test-shouldntexist"})
if err != nil {
t.Errorf("Stat failed: %v\n", err)
}
if exists {
t.Errorf("Test object exists!")
}
if size != 0 {
t.Errorf("Expected size to be 0, found %v\n", size)
}
// Try statting an object which does exist
size, exists, err = gs.StatObject(&Object{bucket, "test-stat"})
if err != nil {
t.Errorf("Stat failed: %v\n", err)
}
if !exists {
t.Errorf("Test object doesn't exist!")
}
if size != int64(len(testObjectContent)) {
t.Errorf("Test object size is wrong: \nexpected: %v\nfound: %v\n",
len(testObjectContent), size)
}
}
func TestPutObject(t *testing.T) {
gs, bucket := doConfig(t)
now := time.UTC()
testKey := fmt.Sprintf("test-put-%v.%v.%v-%v.%v.%v",
now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second)
shouldRetry, err := gs.PutObject(&Object{bucket, testKey},
&BufferCloser{bytes.NewBufferString(testObjectContent)})
if shouldRetry {
shouldRetry, err = gs.PutObject(&Object{bucket, testKey},
&BufferCloser{bytes.NewBufferString(testObjectContent)})
}
if err != nil {
t.Fatalf("Failed to put object: %v", err)
}
// Just stat to check that it actually uploaded, don't bother reading back
size, exists, err := gs.StatObject(&Object{bucket, testKey})
if !exists {
t.Errorf("Test object doesn't exist!")
}
if size != int64(len(testObjectContent)) {
t.Errorf("Test object size is wrong: \nexpected: %v\nfound: %v\n",
len(testObjectContent), size)
}
}
func TestDeleteObject(t *testing.T) {
gs, bucket := doConfig(t)
// Try deleting a nonexitent file
err := gs.DeleteObject(&Object{bucket, "test-shouldntexist"})
if err == nil {
t.Errorf("Tried to delete nonexistent object, succeeded.")
}
// Create a file, try to delete it
now := time.UTC()
testKey := fmt.Sprintf("test-delete-%v.%v.%v-%v.%v.%v",
now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second)
_, err = gs.PutObject(&Object{bucket, testKey},
&BufferCloser{bytes.NewBufferString("Delete Me")})
if err != nil {
t.Fatalf("Failed to put file to delete.")
}
err = gs.DeleteObject(&Object{bucket, testKey})
if err != nil {
t.Errorf("Failed to delete object: %v", err)
}
}
func TestEnumerateBucket(t *testing.T) {
gs, bucket := doConfig(t)
// Enumerate ALL the things!
objs, err := gs.EnumerateObjects(bucket, "", 0)
if err != nil {
t.Errorf("Enumeration failed: %v\n", err)
} else if len(objs) < 7 {
// Minimum number of blobs, equal to the number of files in testdata
t.Errorf("Expected at least 7 files, found %v", len(objs))
}
// Test a limited enum
objs, err = gs.EnumerateObjects(bucket, "", 5)
if err != nil {
t.Errorf("Enumeration failed: %v\n", err)
} else if len(objs) != 5 {
t.Errorf(
"Limited enum returned wrong number of blobs.\nExpected: %v\nFound: %v",
5, len(objs))
}
// Test fetching a limited set from a known start point
objs, err = gs.EnumerateObjects(bucket, "test-enum", 4)
if err != nil {
t.Errorf("Enumeration failed: %v\n", err)
} else {
for i := 0; i < 4; i += 1 {
if objs[i].Key != fmt.Sprintf("test-enum-%v", i+1) {
t.Errorf(
"Enum from start point returned wrong key:\nExpected: test-enum-%v\nFound: %v",
i+1, objs[i].Key)
}
}
}
}

View File

@ -0,0 +1 @@
{"gsconf": ["_fileobj", "gstestconfig.json"]}

View File

@ -0,0 +1 @@
Google Storage Test

View File

@ -0,0 +1 @@
1

View File

@ -0,0 +1 @@
2

View File

@ -0,0 +1 @@
3

View File

@ -0,0 +1 @@
4

View File

@ -0,0 +1 @@
Google Storage Test

View File

@ -0,0 +1 @@
Google Storage Test

View File

@ -144,7 +144,7 @@ func (t *Transport) Exchange(code string) (tok *Token, err os.Error) {
}
// RoundTrip executes a single HTTP transaction using the Transport's
// Token as authorization headers.
// Token as authorization headers.
func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err os.Error) {
if t.Config == nil {
return nil, os.NewError("no Config supplied")
@ -159,18 +159,10 @@ func (t *Transport) RoundTrip(req *http.Request) (resp *http.Response, err os.Er
return
}
// Refresh credentials if they're stale and try again
if resp.StatusCode == 401 {
if err = t.refresh(); err != nil {
return
}
resp, err = t.transport().RoundTrip(req)
}
return
}
func (t *Transport) refresh() os.Error {
func (t *Transport) Refresh() os.Error {
return t.updateToken(t.Token, http.Values{
"grant_type": {"refresh_token"},
"refresh_token": {t.RefreshToken},

View File

@ -82,7 +82,8 @@ class CamliOp(object):
server_address,
buffer_size=BUFFER_SIZE,
create_connection=httplib.HTTPConnection,
auth=None):
auth=None,
basepath=False):
"""Initializer.
Args:
@ -91,6 +92,8 @@ class CamliOp(object):
client-related operations.
create_connection: Use for testing.
auth: Optional. 'username:password' to use for HTTP basic auth.
basepath: Optional path suffix. e.g. if the server is at
"localhost:3179/bs", the basepath should be "/bs".
"""
self.server_address = server_address
self.buffer_size = buffer_size
@ -99,8 +102,16 @@ class CamliOp(object):
self._authorization = ''
if auth:
if len(auth.split(':')) != 2:
logging.fatal('Invalid auth string; should be username:password')
# Default to dummy username; current server doesn't care
# TODO(jrabbit): care when necessary
auth = "username:" + auth #If username not given use the implicit default, 'username'
self._authorization = ('Basic ' + base64.encodestring(auth).strip())
if basepath:
if '/' not in basepath:
raise NameError("basepath must be in form '/bs'")
if basepath[-1] == '/':
basepath = basepath[:-1]
self.basepath = basepath
def _setup_connection(self):
"""Sets up the HTTP connection."""
@ -144,8 +155,12 @@ class CamliOp(object):
# after that we need to do batching in smaller groups.
self._setup_connection()
if self.basepath:
fullpath = self.basepath + '/camli/stat'
else:
fullpath = '/camli/stat'
self.connection.request(
'POST', '/camli/preupload', urllib.urlencode(preupload),
'POST', fullpath, urllib.urlencode(preupload),
{'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': self._authorization})
response = self.connection.getresponse()
@ -162,9 +177,9 @@ class CamliOp(object):
raise PayloadError('Server returned bad preupload response: %r' % data)
logging.debug('Parsed preupload response: %r', response_dict)
if 'alreadyHave' not in response_dict:
if 'stat' not in response_dict:
raise PayloadError(
'Could not find "alreadyHave" in preupload response: %r' %
'Could not find "stat" in preupload response: %r' %
response_dict)
if 'uploadUrl' not in response_dict:
raise PayloadError(
@ -172,7 +187,7 @@ class CamliOp(object):
response_dict)
already_have_blobrefs = set()
for blobref_json in response_dict['alreadyHave']:
for blobref_json in response_dict['stat']:
if 'blobRef' not in blobref_json:
raise PayloadError(
'Cannot find "blobRef" in preupload response: %r',

16
lib/python/setup.py Normal file
View File

@ -0,0 +1,16 @@
from setuptools import setup
setup(
name='camlistore-client',
version='1.0.3dev',
author='Brett Slatkin',
author_email='bslatkin@gmail.com',
maintainer='Jack Laxson',
maintainer_email='jackjrabbit+camli@gmail.com',
description="Client library for Camlistore.",
url='http://camlistore.org',
license='Apache v2',
long_description='A convience library for python developers wishing to explore camlistore.',
packages=['camli'],
install_requires=['simplejson'],
classifiers=['Environment :: Console', 'Topic :: Internet :: WWW/HTTP']
)

View File

@ -41,9 +41,7 @@ function handleFormGetTagged(e) {
}
var tags = input.value.split(/\s*,\s*/);
CamliSearch.query = tags[0];
CamliSearch.type = "tag";
doSearch();
document.location.href = "search.html?q=" + tags[0] + "&t=tag"
}
function doSearch() {
@ -144,8 +142,8 @@ function handleCreateNewCollection(e) {
}
function handleAddToCollection(e) {
e.stopPropagation();
e.preventDefault();
e.stopPropagation();
e.preventDefault();
addToCollection(false)
}