Merge pull request #31142 from overleaf/ar-promisify-web-api-manager

[real-time] promisify web api manager

GitOrigin-RevId: da2677c05dd7d066b0a625763d4158b28671615e
This commit is contained in:
Andrew Rumble
2026-02-04 13:27:41 +00:00
committed by Copybot
parent d9cf720566
commit f434bc3825
8 changed files with 188 additions and 206 deletions

2
package-lock.json generated
View File

@@ -57039,6 +57039,7 @@
"@overleaf/logger": "*", "@overleaf/logger": "*",
"@overleaf/metrics": "*", "@overleaf/metrics": "*",
"@overleaf/o-error": "*", "@overleaf/o-error": "*",
"@overleaf/promise-utils": "*",
"@overleaf/redis-wrapper": "*", "@overleaf/redis-wrapper": "*",
"@overleaf/settings": "*", "@overleaf/settings": "*",
"@overleaf/validation-tools": "*", "@overleaf/validation-tools": "*",
@@ -57052,7 +57053,6 @@
"express-session": "^1.17.1", "express-session": "^1.17.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"request": "2.88.2",
"socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12", "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12",
"socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5",
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"

View File

@@ -17,6 +17,7 @@ COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.
COPY libraries/logger/package.json /overleaf/libraries/logger/package.json COPY libraries/logger/package.json /overleaf/libraries/logger/package.json
COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json
COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json
COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json
COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json
COPY libraries/settings/package.json /overleaf/libraries/settings/package.json COPY libraries/settings/package.json /overleaf/libraries/settings/package.json
COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json
@@ -28,6 +29,7 @@ COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/
COPY libraries/logger/ /overleaf/libraries/logger/ COPY libraries/logger/ /overleaf/libraries/logger/
COPY libraries/metrics/ /overleaf/libraries/metrics/ COPY libraries/metrics/ /overleaf/libraries/metrics/
COPY libraries/o-error/ /overleaf/libraries/o-error/ COPY libraries/o-error/ /overleaf/libraries/o-error/
COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/
COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/
COPY libraries/settings/ /overleaf/libraries/settings/ COPY libraries/settings/ /overleaf/libraries/settings/
COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/

View File

@@ -19,6 +19,7 @@ IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
$(MONOREPO)/libraries/logger/package.json \ $(MONOREPO)/libraries/logger/package.json \
$(MONOREPO)/libraries/metrics/package.json \ $(MONOREPO)/libraries/metrics/package.json \
$(MONOREPO)/libraries/o-error/package.json \ $(MONOREPO)/libraries/o-error/package.json \
$(MONOREPO)/libraries/promise-utils/package.json \
$(MONOREPO)/libraries/redis-wrapper/package.json \ $(MONOREPO)/libraries/redis-wrapper/package.json \
$(MONOREPO)/libraries/settings/package.json \ $(MONOREPO)/libraries/settings/package.json \
$(MONOREPO)/libraries/validation-tools/package.json \ $(MONOREPO)/libraries/validation-tools/package.json \

View File

@@ -1,8 +1,10 @@
import request from 'request' import { callbackifyMultiResult } from '@overleaf/promise-utils'
import OError from '@overleaf/o-error' import OError from '@overleaf/o-error'
import settings from '@overleaf/settings' import settings from '@overleaf/settings'
import logger from '@overleaf/logger' import logger from '@overleaf/logger'
import Errors from './Errors.js' import Errors from './Errors.js'
import Path from 'node:path'
import { fetchJson, RequestFailedError } from '@overleaf/fetch-utils'
const { const {
CodedError, CodedError,
@@ -11,55 +13,62 @@ const {
WebApiRequestFailedError, WebApiRequestFailedError,
} = Errors } = Errors
export default { async function joinProject(projectId, user) {
joinProject(projectId, user, callback) { const userId = user._id
const userId = user._id logger.debug({ projectId, userId }, 'sending join project request to web')
logger.debug({ projectId, userId }, 'sending join project request to web') const url = new URL(settings.apis.web.url)
const url = `${settings.apis.web.url}/project/${projectId}/join` url.pathname = Path.posix.join('project', projectId, 'join')
request.post( let data
{ try {
url, data = await fetchJson(url, {
auth: { method: 'POST',
user: settings.apis.web.user, basicAuth: {
pass: settings.apis.web.pass, user: settings.apis.web.user,
sendImmediately: true, password: settings.apis.web.pass,
},
json: {
userId,
anonymousAccessToken: user.anonymousAccessToken,
},
jar: false,
}, },
function (error, response, data) { json: {
if (error) { userId,
OError.tag(error, 'join project request failed') anonymousAccessToken: user.anonymousAccessToken,
return callback(error) },
} })
if (response.statusCode >= 200 && response.statusCode < 300) { } catch (error) {
if (!(data && data.project)) { if (error instanceof RequestFailedError) {
return callback(new CorruptedJoinProjectResponseError()) if (error.response.status === 429) {
} throw new CodedError(
const userMetadata = { 'rate-limit hit when joining project',
isRestrictedUser: data.isRestrictedUser, 'TooManyRequests'
isTokenMember: data.isTokenMember, )
isInvitedMember: data.isInvitedMember, } else if (error.response.status === 403) {
} throw new NotAuthorizedError()
callback(null, data.project, data.privilegeLevel, userMetadata) } else if (error.response.status === 404) {
} else if (response.statusCode === 429) { throw new CodedError('project not found', 'ProjectNotFound')
callback(
new CodedError(
'rate-limit hit when joining project',
'TooManyRequests'
)
)
} else if (response.statusCode === 403) {
callback(new NotAuthorizedError())
} else if (response.statusCode === 404) {
callback(new CodedError('project not found', 'ProjectNotFound'))
} else {
callback(new WebApiRequestFailedError(response.statusCode))
}
} }
) throw new WebApiRequestFailedError(error.response.status)
}
throw OError.tag(error, 'join project request failed')
}
if (!(data && data.project)) {
throw new CorruptedJoinProjectResponseError()
}
const userMetadata = {
isRestrictedUser: data.isRestrictedUser,
isTokenMember: data.isTokenMember,
isInvitedMember: data.isInvitedMember,
}
return {
project: data.project,
privilegeLevel: data.privilegeLevel,
userMetadata,
}
}
export default {
joinProject: callbackifyMultiResult(joinProject, [
'project',
'privilegeLevel',
'userMetadata',
]),
promises: {
joinProject,
}, },
} }

View File

@@ -20,6 +20,7 @@
"@overleaf/logger": "*", "@overleaf/logger": "*",
"@overleaf/metrics": "*", "@overleaf/metrics": "*",
"@overleaf/o-error": "*", "@overleaf/o-error": "*",
"@overleaf/promise-utils": "*",
"@overleaf/redis-wrapper": "*", "@overleaf/redis-wrapper": "*",
"@overleaf/settings": "*", "@overleaf/settings": "*",
"@overleaf/validation-tools": "*", "@overleaf/validation-tools": "*",
@@ -33,7 +34,6 @@
"express-session": "^1.17.1", "express-session": "^1.17.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"proxy-addr": "^2.0.7", "proxy-addr": "^2.0.7",
"request": "2.88.2",
"socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12", "socket.io": "github:overleaf/socket.io#0.9.19-overleaf-12",
"socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5", "socket.io-client": "github:overleaf/socket.io-client#0.9.17-overleaf-5",
"zod-validation-error": "^4.0.1" "zod-validation-error": "^4.0.1"

View File

@@ -10,16 +10,12 @@ import RealTimeClient from './helpers/RealTimeClient.js'
import FixturesManager from './helpers/FixturesManager.js' import FixturesManager from './helpers/FixturesManager.js'
import { expect } from 'chai' import { expect } from 'chai'
import async from 'async' import async from 'async'
import request from 'request' import { fetchNothing } from '@overleaf/fetch-utils'
const drain = function (rate, callback) { const drain = async function (rate) {
request.post( await fetchNothing(`http://127.0.0.1:3026/drain?rate=${rate}`, {
{ method: 'POST',
url: `http://127.0.0.1:3026/drain?rate=${rate}`, })
},
(error, response, data) => callback(error, data)
)
return null
} }
describe('DrainManagerTests', function () { describe('DrainManagerTests', function () {
@@ -34,7 +30,7 @@ describe('DrainManagerTests', function () {
(e, { project_id: projectId, user_id: userId }) => { (e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId this.project_id = projectId
this.user_id = userId this.user_id = userId
return done() done()
} }
) )
return null return null
@@ -43,23 +39,23 @@ describe('DrainManagerTests', function () {
before(function (done) { before(function (done) {
// cleanup to speedup reconnecting // cleanup to speedup reconnecting
this.timeout(10000) this.timeout(10000)
return RealTimeClient.disconnectAllClients(done) RealTimeClient.disconnectAllClients(done)
}) })
// trigger and check cleanup // trigger and check cleanup
it('should have disconnected all previous clients', function (done) { it('should have disconnected all previous clients', function (done) {
return RealTimeClient.getConnectedClients((error, data) => { RealTimeClient.getConnectedClients((error, data) => {
if (error) { if (error) {
return done(error) return done(error)
} }
expect(data.length).to.equal(0) expect(data.length).to.equal(0)
return done() done()
}) })
}) })
return describe('with two clients in the project', function () { describe('with two clients in the project', function () {
beforeEach(function (done) { beforeEach(function (done) {
return async.series( async.series(
[ [
cb => { cb => {
this.clientA = RealTimeClient.connect(this.project_id, cb) this.clientA = RealTimeClient.connect(this.project_id, cb)
@@ -73,34 +69,37 @@ describe('DrainManagerTests', function () {
) )
}) })
return describe('starting to drain', function () { describe('starting to drain', function () {
beforeEach(function (done) { beforeEach(function (done) {
return async.parallel( async.parallel(
[ [
cb => { cb => {
return this.clientA.on('reconnectGracefully', cb) this.clientA.on('reconnectGracefully', cb)
}, },
cb => { cb => {
return this.clientB.on('reconnectGracefully', cb) this.clientB.on('reconnectGracefully', cb)
}, },
cb => drain(2, cb), cb =>
drain(2)
.then(() => cb())
.catch(cb),
], ],
done done
) )
}) })
afterEach(function (done) { afterEach(async function () {
return drain(0, done) await drain(0)
}) // reset drain }) // reset drain
it('should not timeout', function () { it('should not timeout', function () {
return expect(true).to.equal(true) expect(true).to.equal(true)
}) })
return it('should not have disconnected', function () { it('should not have disconnected', function () {
expect(this.clientA.socket.connected).to.equal(true) expect(this.clientA.socket.connected).to.equal(true)
return expect(this.clientB.socket.connected).to.equal(true) expect(this.clientB.socket.connected).to.equal(true)
}) })
}) })
}) })

View File

@@ -6,32 +6,26 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/ */
import async from 'async' import async from 'async'
import Request from 'request' import {
fetchJson,
fetchNothing,
RequestFailedError,
} from '@overleaf/fetch-utils'
import { expect } from 'chai' import { expect } from 'chai'
import RealTimeClient from './helpers/RealTimeClient.js' import RealTimeClient from './helpers/RealTimeClient.js'
import FixturesManager from './helpers/FixturesManager.js' import FixturesManager from './helpers/FixturesManager.js'
const request = Request.defaults({
baseUrl: 'http://127.0.0.1:3026',
})
describe('HttpControllerTests', function () { describe('HttpControllerTests', function () {
describe('without a user', function () { describe('without a user', function () {
it('should return 404 for the client view', function (done) { it('should return 404 for the client view', async function () {
const clientId = 'not-existing' const clientId = 'not-existing'
request.get( try {
{ await fetchNothing(`http://127.0.0.1:3026/clients/${clientId}`)
url: `/clients/${clientId}`, expect.fail('request should have failed')
json: true, } catch (error) {
}, expect(error).to.be.instanceof(RequestFailedError)
(error, response, data) => { expect(error.response.status).to.equal(404)
if (error) { }
return done(error)
}
expect(response.statusCode).to.equal(404)
done()
}
)
}) })
}) })
@@ -75,32 +69,22 @@ describe('HttpControllerTests', function () {
) )
}) })
it('should send a client view', function (done) { it('should send a client view', async function () {
request.get( const data = await fetchJson(
{ `http://127.0.0.1:3026/clients/${this.client.socket.sessionid}`
url: `/clients/${this.client.socket.sessionid}`,
json: true,
},
(error, response, data) => {
if (error) {
return done(error)
}
expect(response.statusCode).to.equal(200)
expect(data.connected_time).to.exist
delete data.connected_time
// .email is not set in the session
delete data.email
expect(data).to.deep.equal({
client_id: this.client.socket.sessionid,
first_name: 'Joe',
last_name: 'Bloggs',
project_id: this.project_id,
user_id: this.user_id,
rooms: [this.project_id, this.doc_id],
})
done()
}
) )
expect(data.connected_time).to.exist
delete data.connected_time
// .email is not set in the session
delete data.email
expect(data).to.deep.equal({
client_id: this.client.socket.sessionid,
first_name: 'Joe',
last_name: 'Bloggs',
project_id: this.project_id,
user_id: this.user_id,
rooms: [this.project_id, this.doc_id],
})
}) })
}) })
}) })

View File

@@ -1,16 +1,6 @@
import { vi, describe, beforeEach, it } from 'vitest' import { vi, describe, beforeEach, it } from 'vitest'
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import sinon from 'sinon' import sinon from 'sinon'
import { RequestFailedError } from '@overleaf/fetch-utils'
const modulePath = '../../../app/js/WebApiManager.js' const modulePath = '../../../app/js/WebApiManager.js'
@@ -21,9 +11,12 @@ describe('WebApiManager', function () {
ctx.user = { _id: ctx.user_id } ctx.user = { _id: ctx.user_id }
ctx.callback = sinon.stub() ctx.callback = sinon.stub()
vi.doMock('request', () => ({ ctx.fetchUtils = {
default: (ctx.request = {}), fetchJson: sinon.stub(),
})) RequestFailedError,
}
vi.doMock('@overleaf/fetch-utils', () => ctx.fetchUtils)
vi.doMock('@overleaf/settings', () => ({ vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = { default: (ctx.settings = {
@@ -37,10 +30,10 @@ describe('WebApiManager', function () {
}), }),
})) }))
return (ctx.WebApiManager = (await import(modulePath)).default) ctx.WebApiManager = (await import(modulePath)).default
}) })
return describe('joinProject', function () { describe('joinProject', function () {
describe('successfully', function () { describe('successfully', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.response = { ctx.response = {
@@ -50,36 +43,29 @@ describe('WebApiManager', function () {
isTokenMember: true, isTokenMember: true,
isInvitedMember: true, isInvitedMember: true,
} }
ctx.request.post = sinon ctx.fetchUtils.fetchJson.resolves(ctx.response)
.stub() ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
.callsArgWith(1, null, { statusCode: 200 }, ctx.response)
return ctx.WebApiManager.joinProject(
ctx.project_id,
ctx.user,
ctx.callback
)
}) })
it('should send a request to web to join the project', function (ctx) { it('should send a request to web to join the project', function (ctx) {
return ctx.request.post ctx.fetchUtils.fetchJson.should.have.been.calledWith(
.calledWith({ new URL(`/project/${ctx.project_id}/join`, ctx.settings.apis.web.url),
url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`, {
auth: { method: 'POST',
basicAuth: {
user: ctx.settings.apis.web.user, user: ctx.settings.apis.web.user,
pass: ctx.settings.apis.web.pass, password: ctx.settings.apis.web.pass,
sendImmediately: true,
}, },
json: { json: {
userId: ctx.user_id, userId: ctx.user_id,
anonymousAccessToken: undefined, anonymousAccessToken: undefined,
}, },
jar: false, }
}) )
.should.equal(true)
}) })
return it('should return the project, privilegeLevel, and restricted flag', function (ctx) { it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
return ctx.callback ctx.callback
.calledWith(null, ctx.response.project, ctx.response.privilegeLevel, { .calledWith(null, ctx.response.project, ctx.response.privilegeLevel, {
isRestrictedUser: ctx.response.isRestrictedUser, isRestrictedUser: ctx.response.isRestrictedUser,
isTokenMember: ctx.response.isTokenMember, isTokenMember: ctx.response.isTokenMember,
@@ -104,26 +90,25 @@ describe('WebApiManager', function () {
isTokenMember: false, isTokenMember: false,
isInvitedMember: false, isInvitedMember: false,
} }
ctx.request.post = sinon ctx.fetchUtils.fetchJson.resolves(ctx.response)
.stub()
.yields(null, { statusCode: 200 }, ctx.response)
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback) ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
}) })
it('should send a request to web to join the project', function (ctx) { it('should send a request to web to join the project', function (ctx) {
ctx.request.post.should.have.been.calledWith({ ctx.fetchUtils.fetchJson.should.have.been.calledWith(
url: `${ctx.settings.apis.web.url}/project/${ctx.project_id}/join`, new URL(`/project/${ctx.project_id}/join`, ctx.settings.apis.web.url),
auth: { {
user: ctx.settings.apis.web.user, method: 'POST',
pass: ctx.settings.apis.web.pass, basicAuth: {
sendImmediately: true, user: ctx.settings.apis.web.user,
}, password: ctx.settings.apis.web.pass,
json: { },
userId: ctx.user_id, json: {
anonymousAccessToken: ctx.token, userId: ctx.user_id,
}, anonymousAccessToken: ctx.token,
jar: false, },
}) }
)
}) })
it('should return the project, privilegeLevel, and restricted flag', function (ctx) { it('should return the project, privilegeLevel, and restricted flag', function (ctx) {
@@ -142,9 +127,13 @@ describe('WebApiManager', function () {
describe('when web replies with a 403', function () { describe('when web replies with a 403', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.request.post = sinon ctx.fetchUtils.fetchJson.rejects(
.stub() new RequestFailedError(
.callsArgWith(1, null, { statusCode: 403 }, null) `/project/${ctx.project_id}/join`,
{ method: 'POST' },
{ status: 403 }
)
)
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback) ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
}) })
@@ -161,9 +150,13 @@ describe('WebApiManager', function () {
describe('when web replies with a 404', function () { describe('when web replies with a 404', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.request.post = sinon ctx.fetchUtils.fetchJson.rejects(
.stub() new RequestFailedError(
.callsArgWith(1, null, { statusCode: 404 }, null) `/project/${ctx.project_id}/join`,
{ method: 'POST' },
{ status: 404 }
)
)
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback) ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
}) })
@@ -181,18 +174,18 @@ describe('WebApiManager', function () {
describe('with an error from web', function () { describe('with an error from web', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.request.post = sinon ctx.fetchUtils.fetchJson.rejects(
.stub() new RequestFailedError(
.callsArgWith(1, null, { statusCode: 500 }, null) `/project/${ctx.project_id}/join`,
return ctx.WebApiManager.joinProject( { method: 'POST' },
ctx.project_id, { status: 500 }
ctx.user_id, )
ctx.callback
) )
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
}) })
return it('should call the callback with an error', function (ctx) { it('should call the callback with an error', function (ctx) {
return ctx.callback ctx.callback
.calledWith( .calledWith(
sinon.match({ sinon.match({
message: 'non-success status code from web', message: 'non-success status code from web',
@@ -205,18 +198,12 @@ describe('WebApiManager', function () {
describe('with no data from web', function () { describe('with no data from web', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.request.post = sinon ctx.fetchUtils.fetchJson.resolves(null)
.stub() ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
.callsArgWith(1, null, { statusCode: 200 }, null)
return ctx.WebApiManager.joinProject(
ctx.project_id,
ctx.user_id,
ctx.callback
)
}) })
return it('should call the callback with an error', function (ctx) { it('should call the callback with an error', function (ctx) {
return ctx.callback ctx.callback
.calledWith( .calledWith(
sinon.match({ sinon.match({
message: 'no data returned from joinProject request', message: 'no data returned from joinProject request',
@@ -226,20 +213,20 @@ describe('WebApiManager', function () {
}) })
}) })
return describe('when the project is over its rate limit', function () { describe('when the project is over its rate limit', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.request.post = sinon ctx.fetchUtils.fetchJson.rejects(
.stub() new RequestFailedError(
.callsArgWith(1, null, { statusCode: 429 }, null) `/project/${ctx.project_id}/join`,
return ctx.WebApiManager.joinProject( { method: 'POST' },
ctx.project_id, { status: 429 }
ctx.user_id, )
ctx.callback
) )
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user_id, ctx.callback)
}) })
return it('should call the callback with a TooManyRequests error code', function (ctx) { it('should call the callback with a TooManyRequests error code', function (ctx) {
return ctx.callback ctx.callback
.calledWith( .calledWith(
sinon.match({ sinon.match({
message: 'rate-limit hit when joining project', message: 'rate-limit hit when joining project',