diff --git a/src/backends/gitlab/API.js b/src/backends/gitlab/API.js index cae2ad58c799..c6b384221e92 100644 --- a/src/backends/gitlab/API.js +++ b/src/backends/gitlab/API.js @@ -1,6 +1,9 @@ import LocalForage from "Lib/LocalForage"; import { Base64 } from "js-base64"; -import { isString } from "lodash"; +import { fromJS } from "immutable"; +import { flow, isString, partial, partialRight, omit, set, update } from "lodash"; +import unsentRequest from "Lib/unsentRequest"; +import { then } from "Lib/promiseHelper"; import AssetProxy from "ValueObjects/AssetProxy"; import { APIError } from "ValueObjects/errors"; @@ -13,108 +16,68 @@ export default class API { this.repoURL = `/projects/${ encodeURIComponent(this.repo) }`; } - user() { - return this.request("/user").then(({ data }) => data); - } - - hasWriteAccess(user) { - const WRITE_ACCESS = 30; - return this.request(this.repoURL).then(({ data: { permissions } }) => { - const { project_access, group_access } = permissions; - if (project_access && (project_access.access_level >= WRITE_ACCESS)) { - return true; - } - if (group_access && (group_access.access_level >= WRITE_ACCESS)) { - return true; - } - return false; - }); - } - - requestHeaders(headers = {}) { - return { - ...headers, - ...(this.token ? { Authorization: `Bearer ${ this.token }` } : {}), - }; - } - - urlFor(path, options) { - const cacheBuster = `ts=${ new Date().getTime() }`; - const encodedParams = options.params - ? Object.entries(options.params).map( - ([key, val]) => `${ key }=${ encodeURIComponent(val) }`) - : []; - return `${ this.api_root }${ path }?${ [cacheBuster, ...encodedParams].join("&") }`; - } - - requestRaw(path, options = {}) { - const headers = this.requestHeaders(options.headers || {}); - const url = this.urlFor(path, options); - return fetch(url, { ...options, headers }); - } - - getResponseData(res, options = {}) { - return Promise.resolve(res).then((response) => { - const contentType = response.headers.get("Content-Type"); - if (options.method === "HEAD" || options.method === "DELETE") { - return Promise.all([response]); - } - if (contentType && contentType.match(/json/)) { - return Promise.all([response, response.json()]); - } - return Promise.all([response, response.text()]); - }) - .catch(err => Promise.reject([err, null])) - .then(([response, data]) => (response.ok ? { data, response } : Promise.reject([data, response]))) - .catch(([errorValue, response]) => { - const errorMessageProp = (errorValue && errorValue.message) ? errorValue.message : null; - const message = errorMessageProp || (isString(errorValue) ? errorValue : ""); - throw new APIError(message, response && response.status, 'GitLab', { response, errorValue }); - }); - } - - request(path, options = {}) { - return this.requestRaw(path, options).then(res => this.getResponseData(res, options)); - } - - readFile(path, sha, branch = this.branch) { - const cache = sha ? LocalForage.getItem(`gh.${ sha }`) : Promise.resolve(null); - return cache.then((cached) => { - if (cached) { return cached; } - - return this.request(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, { - params: { ref: branch }, - cache: "no-store", - }) - .then(({ data: result }) => { - if (sha) { - LocalForage.setItem(`gh.${ sha }`, result); - } - return result; - }); + withAuthorizationHeaders = req => + unsentRequest.withHeaders(this.token ? { Authorization: `Bearer ${ this.token }` } : {}, req); + + buildRequest = req => flow([ + unsentRequest.withRoot(this.api_root), + this.withAuthorizationHeaders, + unsentRequest.withTimestamp, + ])(req); + + request = async req => flow([this.buildRequest, unsentRequest.performRequest])(req); + requestURL = url => flow([unsentRequest.fromURL, this.request])(url); + responseToJSON = res => res.json() + responseToText = res => res.text() + requestJSON = req => this.request(req).then(this.responseToJSON); + requestText = req => this.request(req).then(this.responseToText); + requestJSONFromURL = url => this.requestURL(url).then(this.responseToJSON); + requestTextFromURL = url => this.requestURL(url).then(this.responseToText); + + user = () => this.requestJSONFromURL("/user"); + + WRITE_ACCESS = 30; + hasWriteAccess = user => this.requestJSONFromURL(this.repoURL).then(({ permissions }) => { + const { project_access, group_access } = permissions; + if (project_access && (project_access.access_level >= this.WRITE_ACCESS)) { + return true; + } + if (group_access && (group_access.access_level >= this.WRITE_ACCESS)) { + return true; + } + return false; + }); + + readFile = async (path, sha, ref=this.branch) => { + const cachedFile = sha ? await LocalForage.getItem(`gl.${ sha }`) : null; + if (cachedFile) { return cachedFile } + const result = await this.requestText({ + url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, + params: { ref }, + cache: "no-store", }); - } + console.log(result) + if (sha) { LocalForage.setItem(`gl.${ sha }`, result) } + return result; + }; - fileDownloadURL(path, branch = this.branch) { - return this.urlFor(`${this.repoURL}/repository/files/${encodeURIComponent(path)}/raw`, { - params: { ref: branch }, - }); - } + fileDownloadURL = (path, ref=this.branch) => unsentRequest.toURL(this.buildRequest({ + url: `${ this.repoURL }/repository/files/${ encodeURIComponent(path) }/raw`, + params: { ref }, + })); - // TODO: parse links into objects so we can update the token if it - // expires - getCursor(response) { + getCursorFromHeaders = headers => { // indices and page counts are assumed to be zero-based, but the // indices and page counts returned from GitLab are one-based - const index = parseInt(response.headers.get("X-Page"), 10) - 1; - const pageCount = parseInt(response.headers.get("X-Total-Pages"), 10) - 1; - const pageSize = parseInt(response.headers.get("X-Per-Page"), 10); - const count = parseInt(response.headers.get("X-Total"), 10); - const linksRaw = response.headers.get("Link"); + const index = parseInt(headers.get("X-Page"), 10) - 1; + const pageCount = parseInt(headers.get("X-Total-Pages"), 10) - 1; + const pageSize = parseInt(headers.get("X-Per-Page"), 10); + const count = parseInt(headers.get("X-Total"), 10); + const linksRaw = headers.get("Link"); const links = linksRaw.split(",") .map(str => str.trim().split(";")) .map(([linkStr, keyStr]) => [linkStr.trim().match(/<(.*?)>/)[1], keyStr.match(/rel="(.*?)"/)[1]]) - .reduce((acc, [link, key]) => Object.assign(acc, { [key]: link }), {}); + .reduce((acc, [link, key]) => Object.assign(acc, { [key]: unsentRequest.fromURL(link).toJS() }), {}); return { actions: [ ...((links.prev && index > 0) ? ["prev"] : []), @@ -122,37 +85,105 @@ export default class API { ...((links.first && index > 0) ? ["first"] : []), ...((links.last && index < pageCount) ? ["last"] : []), ], - meta: { - index, - count, - pageSize, - pageCount, - }, - data: { - links, - }, + meta: { index, count, pageSize, pageCount }, + data: { links }, }; } - traverseCursor(cursor, action) { + getCursor = ({ headers }) => this.getCursorFromHeaders(headers) + + // Gets a cursor without retrieving the entries by using a HEAD + // request + fetchCursor = flow([unsentRequest.withMethod("HEAD"), this.request, then(this.getCursor)]); + fetchCursorAndEntries = flow([ + unsentRequest.withMethod("GET"), + this.request, + p => Promise.all([p.then(this.getCursor), p.then(this.responseToJSON)]), + then(([cursor, entries]) => ({ cursor, entries })), + ]); + fetchRelativeCursor = async (cursor, action) => this.fetchCursor(cursor.data.links[action]); + + reverseCursor = cursor => fromJS(cursor) + .updateIn(["data", "links"], flow([ + links => links.toJS(), + ({ first, last, next, prev }) => ({ + ...(last ? { first: last } : {}), + ...(first ? { last: first } : {}), + ...(next ? { prev: next } : {}), + ...(prev ? { next: prev } : {}), + }), + ])) + .update("actions", actions => actions.map(action => { + switch (action) { + case "first": return "last"; + case "last": return "first"; + case "next": return "prev"; + case "prev": return "next"; + default: return action; + } + })) + .update("meta", meta => meta.update("index", index => meta.get("pageCount", 0) - index)) + .toJS(); + + getReversedCursor = flow([this.getCursor, this.reverseCursor]); + + // The exported listFiles and traverseCursor reverse the direction + // of the cursors, since GitLab's pagination sorts the opposite way + // we want to sort by default (it sorts by filename _descending_, + // while the CMS defaults to sorting by filename _ascending_, at + // least in the current GitHub backend). This should eventually be + // refactored. + listFiles = async path => { + const firstPageCursor = await this.fetchCursor({ + url: `${ this.repoURL }/repository/tree`, + params: { path, ref: this.branch }, + }); + const lastPageLink = firstPageCursor.data.links.last; + const { entries, cursor } = await this.fetchCursorAndEntries(lastPageLink); + return { files: entries, cursor: this.reverseCursor(cursor) }; + }; + + traverseCursor = async (cursor, action) => { const link = cursor.data.links[action]; - return fetch(link, { headers: this.requestHeaders() }) - .then(res => { - const newCursor = this.getCursor(res); - return this.getResponseData(res) - .then(responseData => ({ entries: responseData.data, cursor: newCursor })); - }); - } + const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link); + console.log({ entries, newCursor }); + return { entries, cursor: this.reverseCursor(newCursor) }; + }; + + toBase64 = str => Promise.resolve(Base64.encode(str)); + fromBase64 = str => Base64.decode(str); + uploadAndCommit = async (item, { commitMessage, updateFile = false, branch = this.branch }) => { + const content = await (item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw)); + const file_path = item.path.replace(/^\//, ""); + const action = (updateFile ? "update" : "create"); + const encoding = "base64"; + const body = JSON.stringify({ + branch, + commit_message: commitMessage, + actions: [{ action, file_path, content, encoding }], + }); - listFiles(path) { - return this.request(`${this.repoURL}/repository/tree`, { - params: { path, ref: this.branch }, + await this.request({ + url: `${ this.repoURL }/repository/commits`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body, }) - .then(({ response, data }) => { - const files = data.filter(file => file.type === "blob"); - const cursor = this.getCursor(response); - return { files, cursor }; - }); + + return { ...item, uploaded: true }; + } + + persistFiles = (files, { commitMessage, newEntry: updateFile }) => + Promise.all(files.map(file => this.uploadAndCommit(file, { commitMessage, updateFile }))); + + deleteFile = (path, commit_message, options = {}) => { + const branch = options.branch || this.branch; + return flow([ + unsentRequest.fromURL, + unsentRequest.withMethod("DELETE"), + unsentRequest.withParams({ commit_message, branch }), + this.requestJSON, + ])(`${ this.repoURL }/repository/files/${ encodeURIComponent(path) }`); } persistFiles(files, options) { @@ -172,37 +203,4 @@ export default class API { params: { commit_message, branch }, }).then(({ data }) => data); } - - toBase64(str) { - return Promise.resolve(Base64.encode(str)); - } - - fromBase64(str) { - return Base64.decode(str); - } - - uploadAndCommit(item, { commitMessage, updateFile = false, branch = this.branch }) { - const content = item instanceof AssetProxy ? item.toBase64() : this.toBase64(item.raw); - // Remove leading slash from path if exists. - const file_path = item.path.replace(/^\//, ''); - - // We cannot use the `/repository/files/:file_path` format here because the file content has to go - // in the URI as a parameter. This overloads the OPTIONS pre-request (at least in Chrome 61 beta). - return content.then(contentBase64 => this.request(`${this.repoURL}/repository/commits`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - branch, - commit_message: commitMessage, - actions: [{ - action: (updateFile ? "update" : "create"), - file_path, - content: contentBase64, - encoding: "base64", - }], - }), - })).then(() => Object.assign({}, item, { uploaded: true })); - } } diff --git a/src/lib/promiseHelper.js b/src/lib/promiseHelper.js index 0d16bd5ec8d4..5577c076f81d 100644 --- a/src/lib/promiseHelper.js +++ b/src/lib/promiseHelper.js @@ -18,3 +18,5 @@ export const resolvePromiseProperties = (obj) => { // resolved values Object.assign({}, obj, zipObject(promiseKeys, resolvedPromises))); }; + +export const then = func => p => ((p && p.then) ? p.then(func) : func(p));