mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge branch '1.9.x' into feat-mocks-public-api
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const marker = '<!-- appwrite-benchmark-results -->';
|
||||
const serviceLabels = ['Account', 'TablesDB', 'Storage', 'Functions'];
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const body = buildComment(core);
|
||||
fs.writeFileSync('benchmark-comment.txt', body);
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (!pullRequest || pullRequest.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find((comment) => {
|
||||
return comment.user?.type === 'Bot' && comment.body?.includes(marker);
|
||||
}) || comments.find((comment) => {
|
||||
return comment.user?.type === 'Bot' && comment.body?.includes('Benchmark results');
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
function buildComment(core) {
|
||||
const before = readSummary('benchmark-before-summary.json', core);
|
||||
const after = readSummary('benchmark-after-summary.json', core);
|
||||
const beforeSamples = readSamples('benchmark-before-samples.json', core);
|
||||
const afterSamples = readSamples('benchmark-after-samples.json', core);
|
||||
const baseRef = markdownText(process.env.BENCHMARK_BASE_REF || 'base');
|
||||
const headRef = markdownText(process.env.BENCHMARK_HEAD_REF || 'head');
|
||||
const rows = benchmarkRows(before, after, beforeSamples, afterSamples);
|
||||
const topWaits = topSamples(afterSamples, 'appwrite_api_waiting', 3);
|
||||
const lines = [
|
||||
marker,
|
||||
'## :sparkles: Benchmark results',
|
||||
'',
|
||||
`Comparing ${baseRef} (before) to ${headRef} (after).`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (before === null) {
|
||||
lines.push('> Before benchmark did not complete; showing current branch metrics only.', '');
|
||||
}
|
||||
if (after === null) {
|
||||
lines.push('> Current branch benchmark did not complete; showing available metrics only.', '');
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'**Before**',
|
||||
'',
|
||||
metricTable(rows, 'before'),
|
||||
'',
|
||||
'**After**',
|
||||
'',
|
||||
metricTable(rows, 'after'),
|
||||
'',
|
||||
'**Delta**',
|
||||
'',
|
||||
'| Scenario | P95 delta (ms) |',
|
||||
'| --- | ---: |',
|
||||
...rows.map(deltaRow),
|
||||
'',
|
||||
'<details>',
|
||||
'<summary><strong>Top API waits</strong></summary>',
|
||||
'',
|
||||
'<br>',
|
||||
'',
|
||||
'| API request | Max wait (ms) |',
|
||||
'| --- | ---: |',
|
||||
...topWaitRows(topWaits),
|
||||
'',
|
||||
'</details>',
|
||||
);
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function readSummary(path, core) {
|
||||
if (!fs.existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
} catch (error) {
|
||||
core?.warning(`Invalid benchmark summary ${path}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSamples(path, core) {
|
||||
if (!fs.existsSync(path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contents = fs.readFileSync(path, 'utf8').trim();
|
||||
if (contents === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return contents
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line)];
|
||||
} catch (error) {
|
||||
core?.warning(`Invalid benchmark sample in ${path}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function benchmarkRows(before, after, beforeSamples, afterSamples) {
|
||||
const beforeServices = serviceStats(beforeSamples);
|
||||
const afterServices = serviceStats(afterSamples);
|
||||
return [
|
||||
{
|
||||
label: 'API total',
|
||||
before: apiSampleStats(beforeSamples) || summaryStats(before, 'appwrite_api_duration'),
|
||||
after: apiSampleStats(afterSamples) || summaryStats(after, 'appwrite_api_duration'),
|
||||
},
|
||||
...serviceLabels.map((label) => ({
|
||||
label,
|
||||
before: beforeServices.get(label) || null,
|
||||
after: afterServices.get(label) || null,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function summaryStats(summary, durationMetric, iterationsMetric = null, rpsMetric = null) {
|
||||
const values = metricValues(summary, durationMetric);
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
p50: values.med ?? null,
|
||||
p95: values['p(95)'] ?? null,
|
||||
iterations: iterationsMetric ? metricValue(summary, iterationsMetric, 'count') : values.count ?? null,
|
||||
rps: rpsMetric ? metricValue(summary, rpsMetric, 'rate') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serviceStats(samples) {
|
||||
const apiSamples = samples.filter((sample) => {
|
||||
return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
|
||||
});
|
||||
const groups = new Map();
|
||||
|
||||
for (const sample of apiSamples) {
|
||||
const service = serviceFromName(sample.data.tags?.name || '');
|
||||
if (!service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serviceSamples = groups.get(service) || [];
|
||||
serviceSamples.push(sample);
|
||||
groups.set(service, serviceSamples);
|
||||
}
|
||||
|
||||
return new Map([...groups.entries()].map(([service, serviceSamples]) => {
|
||||
const values = serviceSamples.map((sample) => sample.data.value);
|
||||
const durationSeconds = sampleWindowSeconds(serviceSamples);
|
||||
return [service, {
|
||||
p50: percentile(values, 50),
|
||||
p95: percentile(values, 95),
|
||||
iterations: values.length,
|
||||
rps: durationSeconds ? values.length / durationSeconds : null,
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
function apiSampleStats(samples) {
|
||||
const apiSamples = samples.filter((sample) => {
|
||||
return sample.metric === 'appwrite_api_duration' && typeof sample.data?.value === 'number';
|
||||
});
|
||||
const values = apiSamples.map((sample) => sample.data.value);
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationSeconds = sampleWindowSeconds(apiSamples);
|
||||
return {
|
||||
p50: percentile(values, 50),
|
||||
p95: percentile(values, 95),
|
||||
iterations: values.length,
|
||||
rps: durationSeconds ? values.length / durationSeconds : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serviceFromName(name) {
|
||||
if (name.startsWith('account.')) {
|
||||
return 'Account';
|
||||
}
|
||||
if (name.startsWith('tablesdb.')) {
|
||||
return 'TablesDB';
|
||||
}
|
||||
if (name.startsWith('storage.') || name.startsWith('tokens.')) {
|
||||
return 'Storage';
|
||||
}
|
||||
if (name.startsWith('functions.')) {
|
||||
return 'Functions';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sampleWindowSeconds(samples) {
|
||||
const times = samples
|
||||
.map((sample) => Date.parse(sample.data?.time))
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
if (times.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max((Math.max(...times) - Math.min(...times)) / 1000, 1);
|
||||
}
|
||||
|
||||
function percentile(values, percentileValue) {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.ceil((percentileValue / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
|
||||
}
|
||||
|
||||
function metricValues(data, metric) {
|
||||
return data?.metrics?.[metric]?.values ?? null;
|
||||
}
|
||||
|
||||
function metricValue(data, metric, stat) {
|
||||
return metricValues(data, metric)?.[stat] ?? null;
|
||||
}
|
||||
|
||||
function metricTable(rows, side) {
|
||||
return [
|
||||
'| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
...rows.map((row) => metricRow(row, side)),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function metricRow(row, side) {
|
||||
const values = row[side];
|
||||
return `| ${row.label} | ${formatMs(values?.p50)} | ${formatMs(values?.p95)} | ${formatCount(values?.iterations)} | ${formatRate(values?.rps)} |`;
|
||||
}
|
||||
|
||||
function deltaRow(row) {
|
||||
return `| ${row.label} | ${formatDelta(row.before?.p95, row.after?.p95)} |`;
|
||||
}
|
||||
|
||||
function topSamples(samples, metric, limit) {
|
||||
const byName = samples.reduce((result, sample) => {
|
||||
if (sample.metric !== metric || typeof sample.data?.value !== 'number') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const name = sample.data.tags?.name || 'unknown';
|
||||
const current = result.get(name);
|
||||
if (!current || sample.data.value > current.value) {
|
||||
result.set(name, { name, value: sample.data.value });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, new Map());
|
||||
|
||||
return [...byName.values()]
|
||||
.sort((left, right) => right.value - left.value)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function topWaitRows(samples) {
|
||||
if (samples.length === 0) {
|
||||
return ['| n/a | n/a |'];
|
||||
}
|
||||
|
||||
return samples.map((sample) => {
|
||||
return `| ${markdownText(sample.name).replace(/\|/g, '\\|')} | ${formatMs(sample.value)} |`;
|
||||
});
|
||||
}
|
||||
|
||||
function markdownText(value) {
|
||||
return String(value || '').replace(/[\r\n]/g, ' ').replace(/[&<>"']/g, (char) => {
|
||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char];
|
||||
});
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
return formatNumber(value, 2);
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
return formatNumber(value, 2);
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
||||
function formatDelta(before, after) {
|
||||
if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const difference = Number((after - before).toFixed(2));
|
||||
return `${difference > 0 ? '+' : ''}${trimNumber(difference)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value, decimals) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return trimNumber(Number(value).toFixed(decimals));
|
||||
}
|
||||
|
||||
function trimNumber(value) {
|
||||
const text = String(value);
|
||||
const trimmed = text.includes('.') ? text.replace(/\.?0+$/, '') : text;
|
||||
return trimmed === '' ? '0' : trimmed;
|
||||
}
|
||||
+148
-63
@@ -7,6 +7,7 @@ concurrency:
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
IMAGE: appwrite-dev
|
||||
K6_VERSION: '0.53.0'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -429,6 +430,8 @@ jobs:
|
||||
include:
|
||||
- service: Databases
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
paratest_processes: 3
|
||||
timeout_minutes: 30
|
||||
- service: Sites
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- service: Functions
|
||||
@@ -439,6 +442,10 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- service: TablesDB
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
paratest_processes: 3
|
||||
timeout_minutes: 30
|
||||
- service: Migrations
|
||||
paratest_processes: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -499,7 +506,7 @@ jobs:
|
||||
with:
|
||||
max_attempts: 2
|
||||
retry_wait_seconds: 60
|
||||
timeout_minutes: 20
|
||||
timeout_minutes: ${{ matrix.timeout_minutes || 20 }}
|
||||
job_id: ${{ job.check_run_id }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
test_dir: tests/e2e/Services/${{ matrix.service }}
|
||||
@@ -512,9 +519,14 @@ jobs:
|
||||
Databases|TablesDB|Functions|Realtime|GraphQL|ProjectWebhooks) FUNCTIONAL_FLAG="" ;;
|
||||
esac
|
||||
|
||||
PARATEST_PROCESSES="${{ matrix.paratest_processes }}"
|
||||
if [ -z "$PARATEST_PROCESSES" ]; then
|
||||
PARATEST_PROCESSES="$(nproc)"
|
||||
fi
|
||||
|
||||
docker compose exec -T \
|
||||
-e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \
|
||||
appwrite vendor/bin/paratest --processes $(nproc) $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
|
||||
appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml
|
||||
|
||||
- name: Failure Logs
|
||||
if: failure()
|
||||
@@ -536,6 +548,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -649,13 +663,19 @@ jobs:
|
||||
|
||||
benchmark:
|
||||
name: Benchmark
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download Docker Image
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -669,80 +689,145 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Load and Start Appwrite
|
||||
- name: Load Appwrite image
|
||||
run: |
|
||||
sed -i 's/traefik/localhost/g' .env
|
||||
docker load --input /tmp/${{ env.IMAGE }}.tar
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
docker tag ${{ env.IMAGE }} ${{ env.IMAGE }}:after
|
||||
|
||||
- name: Install Oha
|
||||
- name: Setup k6
|
||||
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
|
||||
with:
|
||||
k6-version: ${{ env.K6_VERSION }}
|
||||
|
||||
- name: Prepare benchmark before
|
||||
id: benchmark_before_prepare
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "deb [signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian/ stable main" | sudo tee /etc/apt/sources.list.d/azlux.list
|
||||
sudo wget -O /usr/share/keyrings/azlux-archive-keyring.gpg https://azlux.fr/repo.gpg
|
||||
sudo apt update
|
||||
sudo apt install oha
|
||||
oha --version
|
||||
git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
git worktree add --detach /tmp/appwrite-benchmark-before ${{ github.event.pull_request.base.sha }}
|
||||
docker build \
|
||||
--cache-from ${{ env.IMAGE }}:after \
|
||||
--target development \
|
||||
--build-arg DEBUG=false \
|
||||
--build-arg TESTING=true \
|
||||
--build-arg VERSION=dev \
|
||||
--tag ${{ env.IMAGE }}:before \
|
||||
/tmp/appwrite-benchmark-before
|
||||
|
||||
- name: Benchmark PR
|
||||
run: 'oha -z 180s http://localhost/v1/health/version --output-format json > benchmark.json'
|
||||
|
||||
- name: Cleaning
|
||||
run: docker compose down -v
|
||||
|
||||
- name: Installing latest version
|
||||
- name: Start before Appwrite
|
||||
id: benchmark_before_start
|
||||
if: steps.benchmark_before_prepare.outcome == 'success'
|
||||
continue-on-error: true
|
||||
working-directory: /tmp/appwrite-benchmark-before
|
||||
env:
|
||||
_APP_DOMAIN: localhost
|
||||
_APP_CONSOLE_DOMAIN: localhost
|
||||
_APP_DOMAIN_FUNCTIONS: functions.localhost
|
||||
_APP_OPTIONS_ABUSE: disabled
|
||||
run: |
|
||||
rm .env
|
||||
LATEST_TAG=$(curl -fsSL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/appwrite/appwrite/releases/latest | jq -r .tag_name)
|
||||
echo "Latest release tag: $LATEST_TAG"
|
||||
curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/docker-compose.yml" -o docker-compose.yml
|
||||
curl -fsSL "https://raw.githubusercontent.com/appwrite/appwrite/${LATEST_TAG}/.env" -o .env
|
||||
sed -i 's/_APP_OPTIONS_ABUSE=enabled/_APP_OPTIONS_ABUSE=disabled/g' .env
|
||||
docker compose up -d
|
||||
sleep 10
|
||||
docker tag ${{ env.IMAGE }}:before ${{ env.IMAGE }}
|
||||
docker compose up -d --wait --no-build
|
||||
|
||||
- name: Benchmark Latest
|
||||
run: oha -z 180s http://localhost/v1/health/version --output-format json > benchmark-latest.json
|
||||
- name: Prepare benchmark files
|
||||
run: rm -f benchmark-before-summary.json benchmark-after-summary.json benchmark-before-samples.json benchmark-after-samples.json
|
||||
|
||||
- name: Prepare comment
|
||||
- name: Benchmark before
|
||||
if: steps.benchmark_before_start.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
|
||||
env:
|
||||
APPWRITE_ENDPOINT: 'http://localhost/v1'
|
||||
APPWRITE_BENCHMARK_ITERATIONS: '5'
|
||||
APPWRITE_BENCHMARK_VUS: '1'
|
||||
APPWRITE_WORKER_TIMEOUT_MS: '120000'
|
||||
APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-before-summary.json'
|
||||
with:
|
||||
path: tests/benchmarks/http.js
|
||||
flags: --quiet --out json=benchmark-before-samples.json
|
||||
cloud-comment-on-pr: false
|
||||
debug: true
|
||||
|
||||
- name: Stop before Appwrite
|
||||
if: always()
|
||||
run: |
|
||||
echo '## :sparkles: Benchmark results' > benchmark.txt
|
||||
echo ' ' >> benchmark.txt
|
||||
echo "- Requests per second: $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- Requests with 200 status code: $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json)" >> benchmark.txt
|
||||
echo "- P99 latency: $(jq -r '.latencyPercentiles.p99' benchmark.json )" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "## :zap: Benchmark Comparison" >> benchmark.txt
|
||||
echo " " >> benchmark.txt
|
||||
echo "| Metric | This PR | Latest version | " >> benchmark.txt
|
||||
echo "| --- | --- | --- | " >> benchmark.txt
|
||||
echo "| RPS | $(jq -r '.summary.requestsPerSec|tonumber?|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.summary.requestsPerSec|tonumber|floor|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| 200 | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark.json) | $(jq -r '.statusCodeDistribution."200"|tostring|[while(length>0;.[:-3])|.[-3:]]|reverse|join(",")' benchmark-latest.json) | " >> benchmark.txt
|
||||
echo "| P99 | $(jq -r '.latencyPercentiles.p99' benchmark.json ) | $(jq -r '.latencyPercentiles.p99' benchmark-latest.json ) | " >> benchmark.txt
|
||||
if [ -d /tmp/appwrite-benchmark-before ]; then
|
||||
cd /tmp/appwrite-benchmark-before
|
||||
docker compose down -v || true
|
||||
fi
|
||||
|
||||
- name: Wait for benchmark ports
|
||||
if: always()
|
||||
run: |
|
||||
for port in 80 443 8080 9503; do
|
||||
for attempt in $(seq 1 30); do
|
||||
if ! ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ss -ltn | awk '{print $4}' | grep -Eq "[:.]${port}$"; then
|
||||
echo "Port ${port} is still in use after stopping the before stack"
|
||||
ss -ltn
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Start after Appwrite
|
||||
env:
|
||||
_APP_DOMAIN: localhost
|
||||
_APP_CONSOLE_DOMAIN: localhost
|
||||
_APP_DOMAIN_FUNCTIONS: functions.localhost
|
||||
_APP_OPTIONS_ABUSE: disabled
|
||||
run: |
|
||||
docker tag ${{ env.IMAGE }}:after ${{ env.IMAGE }}
|
||||
docker compose up -d --wait --no-build
|
||||
|
||||
- name: Benchmark after
|
||||
id: benchmark_after
|
||||
continue-on-error: true
|
||||
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
|
||||
env:
|
||||
APPWRITE_ENDPOINT: 'http://localhost/v1'
|
||||
APPWRITE_BENCHMARK_ITERATIONS: '5'
|
||||
APPWRITE_BENCHMARK_VUS: '1'
|
||||
APPWRITE_WORKER_TIMEOUT_MS: '120000'
|
||||
APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH: '../../benchmark-before-summary.json'
|
||||
APPWRITE_BENCHMARK_SUMMARY_PATH: 'benchmark-after-summary.json'
|
||||
with:
|
||||
path: tests/benchmarks/http.js
|
||||
flags: --quiet --out json=benchmark-after-samples.json
|
||||
cloud-comment-on-pr: false
|
||||
debug: true
|
||||
|
||||
- name: Stop after Appwrite
|
||||
if: always()
|
||||
run: docker compose down -v || true
|
||||
|
||||
- name: Comment on PR
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
with:
|
||||
script: |
|
||||
const comment = require('./.github/workflows/benchmark-comment.js');
|
||||
await comment({ github, context, core });
|
||||
|
||||
- name: Save results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: benchmark.json
|
||||
path: benchmark.json
|
||||
name: benchmark-results
|
||||
path: |
|
||||
benchmark-comment.txt
|
||||
benchmark-before-summary.json
|
||||
benchmark-after-summary.json
|
||||
benchmark-before-samples.json
|
||||
benchmark-after-samples.json
|
||||
retention-days: 7
|
||||
|
||||
- name: Find Comment
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Benchmark results
|
||||
|
||||
- name: Comment on PR
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-path: benchmark.txt
|
||||
edit-mode: replace
|
||||
- name: Fail benchmark
|
||||
if: always() && steps.benchmark_after.outcome != 'success'
|
||||
run: exit 1
|
||||
|
||||
@@ -130,46 +130,6 @@ Http::patch('/v1/projects/:projectId/oauth2')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/auth/:method')
|
||||
->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'updateAuthStatus',
|
||||
description: '/docs/references/projects/update-auth-status.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
|
||||
->param('status', false, new Boolean(true), 'Set the status of this auth method.')
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) {
|
||||
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
$auth = Config::getParam('auth')[$method] ?? [];
|
||||
$authKey = $auth['key'] ?? '';
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
$auths[$authKey] = $status;
|
||||
|
||||
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
|
||||
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
// Backwards compatibility
|
||||
Http::patch('/v1/projects/:projectId/auth/mock-numbers')
|
||||
->desc('Update the mock numbers for the project')
|
||||
|
||||
+125
-10
@@ -38,6 +38,7 @@ use Utopia\DSN\DSN;
|
||||
use Utopia\Logger\Log;
|
||||
use Utopia\Pools\Group;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Span\Span;
|
||||
use Utopia\System\System;
|
||||
use Utopia\Telemetry\Adapter\None as NoTelemetry;
|
||||
use Utopia\WebSocket\Adapter;
|
||||
@@ -701,6 +702,20 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$project = null;
|
||||
$logUser = null;
|
||||
$authorization = null;
|
||||
$rawSize = $request->getSize();
|
||||
$channelCount = 0;
|
||||
$subscriptionCount = 0;
|
||||
$outboundBytes = 0;
|
||||
$responseCode = 200;
|
||||
$subscriptionMode = 'message';
|
||||
$success = false;
|
||||
|
||||
Span::init('realtime.open');
|
||||
Span::add('realtime.connectionId', $connection);
|
||||
Span::add('realtime.inboundBytes', $rawSize);
|
||||
if (!empty($request->getOrigin())) {
|
||||
Span::add('realtime.origin', $request->getOrigin());
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var Document $project */
|
||||
@@ -750,8 +765,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
throw new Exception(Exception::REALTIME_TOO_MANY_MESSAGES, 'Too many requests');
|
||||
}
|
||||
|
||||
$rawSize = $request->getSize();
|
||||
|
||||
triggerStats([
|
||||
METRIC_REALTIME_INBOUND => $rawSize,
|
||||
], $project->getId());
|
||||
@@ -771,6 +784,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$roles = $user->getRoles($authorization);
|
||||
|
||||
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
|
||||
$channelCount = \count($channels);
|
||||
|
||||
$updateStats = static function (string $projectId, ?string $teamId, string $payloadJson) use ($register, $stats): void {
|
||||
$register->get('telemetry.connectionCounter')->add(1);
|
||||
@@ -808,11 +822,15 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
$realtime->subscribe($project->getId(), $connection, '', $roles, [], [], $user->getId());
|
||||
$realtime->connections[$connection]['authorization'] = $authorization;
|
||||
$server->send([$connection], $connectedPayloadJson);
|
||||
$outboundBytes += \strlen($connectedPayloadJson);
|
||||
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
|
||||
$subscriptionMode = 'message';
|
||||
$success = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$names = array_keys($channels);
|
||||
$subscriptionMode = 'url';
|
||||
|
||||
try {
|
||||
$subscriptions = Realtime::constructSubscriptions(
|
||||
@@ -839,6 +857,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
|
||||
$mapping[$index] = $subscriptionId;
|
||||
}
|
||||
$subscriptionCount = \count($subscriptions);
|
||||
if (!empty($subscriptions)) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')->add(\count($subscriptions), $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
@@ -857,8 +876,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
]);
|
||||
|
||||
$server->send([$connection], $connectedPayloadJson);
|
||||
$outboundBytes += \strlen($connectedPayloadJson);
|
||||
$updateStats($project->getId(), $project->getAttribute('teamId'), $connectedPayloadJson);
|
||||
|
||||
$success = true;
|
||||
|
||||
} catch (Throwable $th) {
|
||||
logError($th, 'realtime', project: $project, user: $logUser, authorization: $authorization);
|
||||
@@ -868,6 +888,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
if (!\is_int($code)) {
|
||||
$code = 500;
|
||||
}
|
||||
$responseCode = $code;
|
||||
|
||||
$message = $th->getMessage();
|
||||
|
||||
@@ -885,7 +906,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
]
|
||||
];
|
||||
|
||||
$server->send([$connection], json_encode($response));
|
||||
$responsePayloadJson = json_encode($response);
|
||||
$server->send([$connection], $responsePayloadJson);
|
||||
$outboundBytes += \strlen($responsePayloadJson);
|
||||
$server->close($connection, $code);
|
||||
|
||||
if (System::getEnv('_APP_ENV', 'production') === 'development') {
|
||||
@@ -893,16 +916,44 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
|
||||
Console::error('[Error] Code: ' . $response['data']['code']);
|
||||
Console::error('[Error] Message: ' . $response['data']['message']);
|
||||
}
|
||||
Span::error($th);
|
||||
} finally {
|
||||
Span::add('realtime.success', $success);
|
||||
Span::add('realtime.responseCode', $responseCode);
|
||||
Span::add('realtime.subscriptionMode', $subscriptionMode);
|
||||
Span::add('realtime.channelCount', $channelCount);
|
||||
Span::add('realtime.subscriptionCount', $subscriptionCount);
|
||||
Span::add('realtime.outboundBytes', $outboundBytes);
|
||||
if (!empty($project?->getId())) {
|
||||
Span::add('realtime.projectId', $project->getId());
|
||||
}
|
||||
if (!empty($logUser?->getId())) {
|
||||
Span::add('realtime.userId', $logUser->getId());
|
||||
}
|
||||
Span::current()?->finish();
|
||||
}
|
||||
});
|
||||
|
||||
$server->onMessage(function (int $connection, string $message) use ($server, $realtime, $containerId, $register) {
|
||||
$project = null;
|
||||
$authorization = null;
|
||||
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
|
||||
$rawSize = \strlen($message);
|
||||
$messageType = 'invalid';
|
||||
$subscriptionDelta = 0;
|
||||
$subscriptionsRequested = 0;
|
||||
$subscriptionsRemoved = 0;
|
||||
$outboundBytes = 0;
|
||||
$responseCode = 200;
|
||||
$success = false;
|
||||
|
||||
Span::init('realtime.message');
|
||||
Span::add('realtime.connectionId', $connection);
|
||||
Span::add('realtime.inboundBytes', $rawSize);
|
||||
Span::add('realtime.containerId', $containerId);
|
||||
|
||||
try {
|
||||
$rawSize = \strlen($message);
|
||||
$response = new Response(new SwooleResponse());
|
||||
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
|
||||
|
||||
// Get authorization from connection (stored during onOpen)
|
||||
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
|
||||
@@ -952,6 +1003,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message format is not valid.');
|
||||
}
|
||||
|
||||
$messageType = $message['type'] ?? 'invalid';
|
||||
|
||||
if (!\is_scalar($messageType)) {
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
|
||||
// Ping does not require project context; other messages do (e.g. after unsubscribe during auth)
|
||||
if (empty($projectId) && ($message['type'] ?? '') !== 'ping') {
|
||||
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing project context. Reconnect to the project first.');
|
||||
@@ -964,6 +1021,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $pongPayloadJson);
|
||||
$outboundBytes += \strlen($pongPayloadJson);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$pongOutboundBytes = \strlen($pongPayloadJson);
|
||||
@@ -1053,6 +1111,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $authResponsePayloadJson);
|
||||
$outboundBytes += \strlen($authResponsePayloadJson);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$authOutboundBytes = \strlen($authResponsePayloadJson);
|
||||
@@ -1114,21 +1173,25 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Invalid query: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$convertedChannels = \array_keys(Realtime::convertChannels($payload['channels'], $userId));
|
||||
|
||||
$parsedPayloads[] = [
|
||||
'subscriptionId' => $subscriptionId,
|
||||
'channels' => $payload['channels'],
|
||||
'convertedChannels' => $convertedChannels,
|
||||
'queries' => $convertedQueries,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($parsedPayloads as $parsedPayload) {
|
||||
$subscriptionId = $parsedPayload['subscriptionId'];
|
||||
$channels = \array_keys(Realtime::convertChannels($parsedPayload['channels'], $userId));
|
||||
$channels = $parsedPayload['convertedChannels'];
|
||||
$queries = $parsedPayload['queries'];
|
||||
$realtime->subscribe($projectId, $connection, $subscriptionId, $roles, $channels, $queries);
|
||||
}
|
||||
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
|
||||
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
|
||||
$subscriptionsRequested = \count($parsedPayloads);
|
||||
if ($subscriptionDelta !== 0) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
@@ -1141,7 +1204,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
'subscriptions' => \array_map(function (array $parsedPayload) {
|
||||
return [
|
||||
'subscriptionId' => $parsedPayload['subscriptionId'],
|
||||
'channels' => $parsedPayload['channels'],
|
||||
'channels' => $parsedPayload['convertedChannels'],
|
||||
'queries' => \array_map(fn ($q) => $q->toString(), $parsedPayload['queries']),
|
||||
];
|
||||
}, $parsedPayloads),
|
||||
@@ -1149,6 +1212,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $responsePayload);
|
||||
$outboundBytes += \strlen($responsePayload);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$subscribeOutboundBytes = \strlen($responsePayload);
|
||||
@@ -1194,6 +1258,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
}
|
||||
$subscriptionsAfter = \count($realtime->getSubscriptionMetadata($connection));
|
||||
$subscriptionDelta = $subscriptionsAfter - $subscriptionsBefore;
|
||||
$subscriptionsRequested = \count($validatedIds);
|
||||
$subscriptionsRemoved = \count(\array_filter($unsubscribeResults, fn (array $item) => $item['removed']));
|
||||
if ($subscriptionDelta !== 0) {
|
||||
$register->get('telemetry.workerSubscriptionCounter')->add($subscriptionDelta, $register->get('telemetry.workerAttributes'));
|
||||
}
|
||||
@@ -1208,6 +1274,7 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]);
|
||||
|
||||
$server->send([$connection], $unsubscribeResponsePayload);
|
||||
$outboundBytes += \strlen($unsubscribeResponsePayload);
|
||||
|
||||
if ($project !== null && !$project->isEmpty()) {
|
||||
$unsubscribeOutboundBytes = \strlen($unsubscribeResponsePayload);
|
||||
@@ -1224,12 +1291,14 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
default:
|
||||
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Message type is not valid.');
|
||||
}
|
||||
$success = true;
|
||||
} catch (Throwable $th) {
|
||||
logError($th, 'realtimeMessage', project: $project, authorization: $authorization);
|
||||
$code = $th->getCode();
|
||||
if (!is_int($code)) {
|
||||
$code = 500;
|
||||
}
|
||||
$responseCode = $code;
|
||||
|
||||
$message = $th->getMessage();
|
||||
|
||||
@@ -1246,15 +1315,43 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
|
||||
]
|
||||
];
|
||||
|
||||
$server->send([$connection], json_encode($response));
|
||||
$responsePayloadJson = json_encode($response);
|
||||
$server->send([$connection], $responsePayloadJson);
|
||||
$outboundBytes += \strlen($responsePayloadJson);
|
||||
|
||||
if ($th->getCode() === 1008) {
|
||||
$server->close($connection, $th->getCode());
|
||||
}
|
||||
Span::error($th);
|
||||
} finally {
|
||||
Span::add('realtime.success', $success);
|
||||
Span::add('realtime.responseCode', $responseCode);
|
||||
Span::add('realtime.subscriptionDelta', $subscriptionDelta);
|
||||
Span::add('realtime.subscriptionsRequested', $subscriptionsRequested);
|
||||
Span::add('realtime.subscriptionsRemoved', $subscriptionsRemoved);
|
||||
Span::add('realtime.subscribe.subscriptionsCount', $subscriptionsRequested);
|
||||
Span::add('realtime.outboundBytes', $outboundBytes);
|
||||
Span::add('realtime.projectId', $project?->getId() ?? $projectId);
|
||||
Span::add('realtime.userId', $realtime->connections[$connection]['userId'] ?? null);
|
||||
Span::add('realtime.messageType', $messageType);
|
||||
Span::current()?->finish();
|
||||
}
|
||||
});
|
||||
|
||||
$server->onClose(function (int $connection) use ($realtime, $stats, $register) {
|
||||
$projectId = null;
|
||||
$userId = null;
|
||||
$subscriptionsBeforeClose = 0;
|
||||
$success = false;
|
||||
|
||||
Span::init('realtime.close');
|
||||
Span::add('realtime.connectionId', $connection);
|
||||
|
||||
if (array_key_exists($connection, $realtime->connections)) {
|
||||
$projectId = $realtime->connections[$connection]['projectId'] ?? null;
|
||||
$userId = $realtime->connections[$connection]['userId'] ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (array_key_exists($connection, $realtime->connections)) {
|
||||
$stats->decr($realtime->connections[$connection]['projectId'], 'connectionsTotal');
|
||||
@@ -1271,12 +1368,30 @@ $server->onClose(function (int $connection) use ($realtime, $stats, $register) {
|
||||
METRIC_REALTIME_CONNECTIONS => -1,
|
||||
], $projectId);
|
||||
}
|
||||
$success = true;
|
||||
} catch (\Throwable $th) {
|
||||
// Log only; do not rethrow. If we let this bubble, Swoole dumps full coroutine
|
||||
// backtraces and unsubscribe() below would never run (connection cleanup would fail).
|
||||
Console::error('Realtime onClose error: ' . $th->getMessage());
|
||||
Span::error($th);
|
||||
} finally {
|
||||
try {
|
||||
$realtime->unsubscribe($connection);
|
||||
} catch (\Throwable $th) {
|
||||
Console::error('Realtime onClose unsubscribe error: ' . $th->getMessage());
|
||||
Span::error($th);
|
||||
}
|
||||
|
||||
Span::add('realtime.success', $success);
|
||||
if (!empty($projectId)) {
|
||||
Span::add('realtime.projectId', $projectId);
|
||||
}
|
||||
if (!empty($userId)) {
|
||||
Span::add('realtime.userId', $userId);
|
||||
}
|
||||
Span::add('realtime.subscriptionsBeforeClose', $subscriptionsBeforeClose);
|
||||
Span::current()?->finish();
|
||||
}
|
||||
$realtime->unsubscribe($connection);
|
||||
|
||||
Console::info('Connection close: ' . $connection);
|
||||
});
|
||||
|
||||
@@ -206,6 +206,12 @@ class Create extends Action
|
||||
if ($chunk === -1) {
|
||||
$chunk = $chunks;
|
||||
}
|
||||
} else {
|
||||
// Guard against manually setting range header for single chunk upload
|
||||
if ($chunks === -1) {
|
||||
$chunks = 1;
|
||||
$chunk = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$chunksUploaded = $deviceForFunctions->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
|
||||
|
||||
@@ -375,7 +375,7 @@ class Create extends Base
|
||||
}
|
||||
|
||||
$functionsDomain = $platform['functionsDomain'];
|
||||
if (!empty($functionsDomain)) {
|
||||
if (!empty($functionsDomain) && isset($deployment) && !$deployment->isEmpty()) {
|
||||
$routeSubdomain = ID::unique();
|
||||
$domain = "{$routeSubdomain}.{$functionsDomain}";
|
||||
// TODO: (@Meldiron) Remove after 1.7.x migration
|
||||
@@ -391,8 +391,8 @@ class Create extends Base
|
||||
'status' => 'verified',
|
||||
'type' => 'deployment',
|
||||
'trigger' => 'manual',
|
||||
'deploymentId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getId(),
|
||||
'deploymentInternalId' => !isset($deployment) || $deployment->isEmpty() ? '' : $deployment->getSequence(),
|
||||
'deploymentId' => $deployment->getId(),
|
||||
'deploymentInternalId' => $deployment->getSequence(),
|
||||
'deploymentResourceType' => 'function',
|
||||
'deploymentResourceId' => $function->getId(),
|
||||
'deploymentResourceInternalId' => $function->getSequence(),
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\AuthMethods;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateProjectAuthMethod';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/project/auth-methods/:methodId')
|
||||
->httpAlias('/v1/projects/:projectId/auth/:methodId')
|
||||
->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
|
||||
->groups(['api', 'project'])
|
||||
->label('scope', 'project.write')
|
||||
->label('event', 'authMethod.[methodId].update')
|
||||
->label('audits.event', 'project.authMethods.[methodId].update')
|
||||
->label('audits.resource', 'project.authMethods/{response.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'project',
|
||||
group: null,
|
||||
name: 'updateAuthMethod',
|
||||
description: <<<EOT
|
||||
Update properties of a specific auth method. Use this endpoint to enable or disable a method in your project.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
],
|
||||
))
|
||||
|
||||
->param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
|
||||
->param('enabled', null, new Boolean(), 'Auth method status.')
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->inject('project')
|
||||
->inject('authorization')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(
|
||||
string $methodId,
|
||||
bool $enabled,
|
||||
Response $response,
|
||||
Database $dbForPlatform,
|
||||
Document $project,
|
||||
Authorization $authorization,
|
||||
Event $queueForEvents
|
||||
): void {
|
||||
$auth = Config::getParam('auth')[$methodId] ?? [];
|
||||
$authKey = $auth['key'] ?? '';
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
$auths[$authKey] = $enabled;
|
||||
|
||||
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'auths' => $auths,
|
||||
])));
|
||||
|
||||
$queueForEvents->setParam('methodId', $methodId);
|
||||
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Appwrite\Platform\Modules\Project\Services;
|
||||
|
||||
use Appwrite\Platform\Modules\Project\Http\Init;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
|
||||
@@ -119,5 +120,8 @@ class Http extends Service
|
||||
$this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy());
|
||||
$this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy());
|
||||
$this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy());
|
||||
|
||||
// Auth Methods
|
||||
$this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ class Create extends Action
|
||||
if ($chunk === -1) {
|
||||
$chunk = $chunks;
|
||||
}
|
||||
} else {
|
||||
// Guard against manually setting range header for single chunk upload
|
||||
if ($chunks === -1) {
|
||||
$chunks = 1;
|
||||
$chunk = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$chunksUploaded = $deviceForSites->upload($fileTmpName, $path, $chunk, $chunks, $metadata);
|
||||
|
||||
@@ -297,6 +297,150 @@ class Specs extends Action
|
||||
];
|
||||
}
|
||||
|
||||
protected function verifyParsedSpec(array $spec): void
|
||||
{
|
||||
$services = [];
|
||||
foreach ($spec['tags'] ?? [] as $tag) {
|
||||
if (!\is_array($tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$service = $tag['name'] ?? null;
|
||||
if (!\is_string($service) || $service === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$services[$this->normalizeSdkName($service)] = $service;
|
||||
}
|
||||
|
||||
if (empty($services)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$enums = [];
|
||||
$this->collectSpecEnumNames($spec, $enums);
|
||||
|
||||
if (empty($enums)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overlaps = [];
|
||||
foreach ($services as $normalized => $service) {
|
||||
if (!isset($enums[$normalized])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($enums[$normalized] as $enum) {
|
||||
$overlaps[] = "service '{$service}' with enum '{$enum}'";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($overlaps)) {
|
||||
throw new \RuntimeException(
|
||||
'Spec service names must not overlap enum names. Overlaps: '
|
||||
. \implode(', ', \array_unique($overlaps))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null, bool $skipCurrentEnum = false): void
|
||||
{
|
||||
if (!$skipCurrentEnum && isset($node['enum']) && \is_array($node['enum'])) {
|
||||
$enumName = $this->getExplicitSpecEnumName($node)
|
||||
?? $this->getFallbackSpecEnumName($node, $fallbackName);
|
||||
|
||||
if (!\is_null($enumName)) {
|
||||
$this->addSpecEnumName($enums, $enumName);
|
||||
}
|
||||
}
|
||||
|
||||
$itemsEnumHandled = false;
|
||||
if (
|
||||
isset($node['items'])
|
||||
&& \is_array($node['items'])
|
||||
&& isset($node['items']['enum'])
|
||||
&& \is_array($node['items']['enum'])
|
||||
) {
|
||||
$enumName = $this->getExplicitSpecEnumName($node['items'])
|
||||
?? $this->getExplicitSpecEnumName($node)
|
||||
?? $this->getFallbackSpecEnumName($node, $fallbackName);
|
||||
|
||||
if (!\is_null($enumName)) {
|
||||
$this->addSpecEnumName($enums, $enumName);
|
||||
}
|
||||
|
||||
$itemsEnumHandled = true;
|
||||
}
|
||||
|
||||
$explicitEnumName = $this->getExplicitSpecEnumName($node);
|
||||
if (!\is_null($explicitEnumName) && !isset($node['enum']) && !$itemsEnumHandled) {
|
||||
$this->addSpecEnumName($enums, $explicitEnumName);
|
||||
}
|
||||
|
||||
foreach ($node as $key => $value) {
|
||||
if (!\is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->collectSpecEnumNames(
|
||||
$value,
|
||||
$enums,
|
||||
$this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName),
|
||||
$key === 'items' && $itemsEnumHandled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function addSpecEnumName(array &$enums, string $name): void
|
||||
{
|
||||
$enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name);
|
||||
}
|
||||
|
||||
private function getExplicitSpecEnumName(array $node): ?string
|
||||
{
|
||||
$enumName = $node['x-enum-name'] ?? null;
|
||||
|
||||
return \is_string($enumName) && $enumName !== '' ? $enumName : null;
|
||||
}
|
||||
|
||||
private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string
|
||||
{
|
||||
$name = $node['name'] ?? $fallbackName;
|
||||
|
||||
return \is_string($name) && $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
private function getChildSpecEnumFallbackName(
|
||||
array $parent,
|
||||
int|string $key,
|
||||
array $child,
|
||||
?string $fallbackName
|
||||
): ?string {
|
||||
if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') {
|
||||
return $child['name'];
|
||||
}
|
||||
|
||||
if ($key === 'schema' || $key === 'items') {
|
||||
return $this->getFallbackSpecEnumName($parent, $fallbackName);
|
||||
}
|
||||
|
||||
if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'delete', 'get', 'head', 'options', 'parameters', 'patch', 'paths', 'post', 'properties', 'put', 'responses'], true)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
return $fallbackName;
|
||||
}
|
||||
|
||||
private function formatSdkName(string $name): string
|
||||
{
|
||||
return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name)));
|
||||
}
|
||||
|
||||
private function normalizeSdkName(string $name): string
|
||||
{
|
||||
return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name));
|
||||
}
|
||||
|
||||
public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array
|
||||
{
|
||||
$sdkPlatforms = [];
|
||||
@@ -483,6 +627,7 @@ class Specs extends Action
|
||||
|
||||
try {
|
||||
$parsedSpecs = $specs->parse();
|
||||
$this->verifyParsedSpec($parsedSpecs);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
@@ -743,6 +743,15 @@ abstract class Format
|
||||
break;
|
||||
case 'project':
|
||||
switch ($method) {
|
||||
case 'getEmailTemplate':
|
||||
case 'updateEmailTemplate':
|
||||
switch ($param) {
|
||||
case 'templateId':
|
||||
return 'EmailTemplateType';
|
||||
case 'locale':
|
||||
return 'EmailTemplateLocale';
|
||||
}
|
||||
break;
|
||||
case 'getUsage':
|
||||
switch ($param) {
|
||||
case 'period':
|
||||
|
||||
@@ -18,6 +18,7 @@ class Request extends UtopiaRequest
|
||||
*/
|
||||
private array $filters = [];
|
||||
private ?Route $route = null;
|
||||
private ?array $filteredParams = null;
|
||||
|
||||
public function __construct(SwooleRequest $request)
|
||||
{
|
||||
@@ -32,6 +33,10 @@ class Request extends UtopiaRequest
|
||||
*/
|
||||
public function getParams(): array
|
||||
{
|
||||
if ($this->filteredParams !== null) {
|
||||
return $this->filteredParams;
|
||||
}
|
||||
|
||||
$parameters = parent::getParams();
|
||||
|
||||
if (!$this->hasFilters() || !$this->hasRoute()) {
|
||||
@@ -49,6 +54,7 @@ class Request extends UtopiaRequest
|
||||
foreach ($this->getFilters() as $filter) {
|
||||
$parameters = $filter->parse($parameters, $id);
|
||||
}
|
||||
$this->filteredParams = $parameters;
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
@@ -79,6 +85,7 @@ class Request extends UtopiaRequest
|
||||
$parameters = $filter->parse($parameters, $id);
|
||||
}
|
||||
|
||||
$this->filteredParams = $parameters;
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
@@ -92,6 +99,7 @@ class Request extends UtopiaRequest
|
||||
public function addFilter(Filter $filter): void
|
||||
{
|
||||
$this->filters[] = $filter;
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +120,7 @@ class Request extends UtopiaRequest
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->filters = [];
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +143,7 @@ class Request extends UtopiaRequest
|
||||
public function setRoute(?Route $route): void
|
||||
{
|
||||
$this->route = $route;
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,18 @@ use Utopia\Database\Query;
|
||||
|
||||
class V20 extends Filter
|
||||
{
|
||||
/**
|
||||
* Per-instance (request-scoped) memo of the `attributes` array for a given
|
||||
* `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection
|
||||
* document when multiple relationships in the same schema point at it, and
|
||||
* when `parse()` is re-entered before `Request::getParams()` memoization warms.
|
||||
*
|
||||
* A `null` value means we already tried and the collection was missing or errored.
|
||||
*
|
||||
* @var array<string, array<int, array<string, mixed>>|null>
|
||||
*/
|
||||
private array $collectionAttributesCache = [];
|
||||
|
||||
// Convert 1.7 params to 1.8
|
||||
public function parse(array $content, string $model): array
|
||||
{
|
||||
@@ -106,36 +118,21 @@ class V20 extends Filter
|
||||
* Recursively includes nested relationships up to 3 levels deep.
|
||||
* Prevents infinite loops by tracking all visited collections in the current path.
|
||||
*/
|
||||
private function getRelatedCollectionKeys(
|
||||
?string $databaseId = null,
|
||||
?string $collectionId = null,
|
||||
?string $prefix = null,
|
||||
int $depth = 1,
|
||||
array $visited = []
|
||||
): array {
|
||||
$databaseId ??= $this->getParamValue('databaseId');
|
||||
$collectionId ??= $this->getParamValue('collectionId');
|
||||
private function getRelatedCollectionKeys(): array
|
||||
{
|
||||
$databaseId = $this->getParamValue('databaseId');
|
||||
$collectionId = $this->getParamValue('collectionId');
|
||||
|
||||
if (
|
||||
empty($databaseId) ||
|
||||
empty($collectionId) ||
|
||||
$depth > Database::RELATION_MAX_DEPTH
|
||||
) {
|
||||
if (empty($databaseId) || empty($collectionId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we've already visited this collection in the current path to prevent cycles
|
||||
if (in_array($collectionId, $visited)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visited[] = $collectionId;
|
||||
|
||||
$dbForProject = $this->getDbForProject();
|
||||
if ($dbForProject === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Resolve the database namespace once, outside the recursion.
|
||||
try {
|
||||
$database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
'databases',
|
||||
@@ -148,19 +145,42 @@ class V20 extends Filter
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
'database_' . $database->getSequence(),
|
||||
$collectionId
|
||||
));
|
||||
if ($collection->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$databaseNamespace = 'database_' . $database->getSequence();
|
||||
|
||||
return $this->walkRelatedCollectionKeys(
|
||||
$dbForProject,
|
||||
$databaseNamespace,
|
||||
$collectionId,
|
||||
null,
|
||||
1,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private function walkRelatedCollectionKeys(
|
||||
Database $dbForProject,
|
||||
string $databaseNamespace,
|
||||
string $collectionId,
|
||||
?string $prefix,
|
||||
int $depth,
|
||||
array $visited
|
||||
): array {
|
||||
if ($depth > Database::RELATION_MAX_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attributes = $collection->getAttribute('attributes', []);
|
||||
// Check if we've already visited this collection in the current path to prevent cycles
|
||||
if (in_array($collectionId, $visited, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId);
|
||||
if ($attributes === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visited[] = $collectionId;
|
||||
|
||||
$relationshipKeys = [];
|
||||
|
||||
foreach ($attributes as $attr) {
|
||||
@@ -176,27 +196,54 @@ class V20 extends Filter
|
||||
$relatedCollectionId = $attr['relatedCollection'] ?? null;
|
||||
|
||||
// Skip this relationship entirely if it points to an already visited collection
|
||||
if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) {
|
||||
if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the wildcard select for this relationship
|
||||
$relationshipKeys[] = $fullKey . '.*';
|
||||
|
||||
// Continue recursively if we have a related collection
|
||||
if ($relatedCollectionId) {
|
||||
$nestedKeys = $this->getRelatedCollectionKeys(
|
||||
$databaseId,
|
||||
$nestedKeys = $this->walkRelatedCollectionKeys(
|
||||
$dbForProject,
|
||||
$databaseNamespace,
|
||||
$relatedCollectionId,
|
||||
$fullKey,
|
||||
$depth + 1,
|
||||
$visited
|
||||
);
|
||||
|
||||
$relationshipKeys = \array_merge($relationshipKeys, $nestedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($relationshipKeys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function getCollectionAttributes(
|
||||
Database $dbForProject,
|
||||
string $databaseNamespace,
|
||||
string $collectionId
|
||||
): ?array {
|
||||
$cacheKey = $databaseNamespace . ':' . $collectionId;
|
||||
if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) {
|
||||
return $this->collectionAttributesCache[$cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
$collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
$databaseNamespace,
|
||||
$collectionId
|
||||
));
|
||||
} catch (\Throwable) {
|
||||
return $this->collectionAttributesCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
if ($collection->isEmpty()) {
|
||||
return $this->collectionAttributesCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ class V23 extends Filter
|
||||
case 'project.updateSessionLimitPolicy':
|
||||
$content = $this->parseLimitToTotal($content);
|
||||
break;
|
||||
case 'project.updateAuthMethod':
|
||||
$content = $this->parseUpdateAuthMethod($content);
|
||||
break;
|
||||
}
|
||||
|
||||
return $content;
|
||||
@@ -60,6 +63,21 @@ class V23 extends Filter
|
||||
return $content;
|
||||
}
|
||||
|
||||
protected function parseUpdateAuthMethod(array $content): array
|
||||
{
|
||||
if (isset($content['status'])) {
|
||||
$content['enabled'] = $content['status'];
|
||||
unset($content['status']);
|
||||
}
|
||||
|
||||
if (isset($content['method'])) {
|
||||
$content['methodId'] = $content['method'];
|
||||
unset($content['method']);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
protected function parseLimitToTotal(array $content): array
|
||||
{
|
||||
if (isset($content['limit'])) {
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export K6_WEB_DASHBOARD="${K6_WEB_DASHBOARD:-true}"
|
||||
export K6_WEB_DASHBOARD_HOST="${K6_WEB_DASHBOARD_HOST:-127.0.0.1}"
|
||||
export K6_WEB_DASHBOARD_PORT="${K6_WEB_DASHBOARD_PORT:-5665}"
|
||||
export K6_WEB_DASHBOARD_EXPORT="${K6_WEB_DASHBOARD_EXPORT:-/tmp/appwrite-k6-report.html}"
|
||||
export APPWRITE_ENDPOINT="${APPWRITE_ENDPOINT:-http://localhost/v1}"
|
||||
export APPWRITE_WORKER_TIMEOUT_MS="${APPWRITE_WORKER_TIMEOUT_MS:-120000}"
|
||||
export APPWRITE_BENCHMARK_SUMMARY_PATH="${APPWRITE_BENCHMARK_SUMMARY_PATH:-/tmp/appwrite-k6-summary.json}"
|
||||
|
||||
samples_path="${APPWRITE_BENCHMARK_SAMPLES_PATH:-/tmp/appwrite-k6-samples.json}"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/../.." && pwd)"
|
||||
|
||||
exec k6 run --out "json=${samples_path}" "$@" "${repo_root}/tests/benchmarks/http.js"
|
||||
+613
-22
@@ -1,34 +1,625 @@
|
||||
/*
|
||||
* Run locally:
|
||||
* Requires k6 and a running Appwrite instance.
|
||||
*
|
||||
* tests/benchmarks/http-local.sh
|
||||
*
|
||||
* Open http://127.0.0.1:5665 while the benchmark is running.
|
||||
*/
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { Counter } from 'k6/metrics';
|
||||
import { check, group, sleep } from 'k6';
|
||||
import encoding from 'k6/encoding';
|
||||
import { Counter, Trend } from 'k6/metrics';
|
||||
|
||||
// A simple counter for http requests
|
||||
export const requests = new Counter('http_reqs');
|
||||
const ENDPOINT = (__ENV.APPWRITE_ENDPOINT || 'http://localhost/v1').replace(/\/+$/, '');
|
||||
const CONSOLE_PROJECT = __ENV.APPWRITE_CONSOLE_PROJECT || 'console';
|
||||
const REGION = __ENV.APPWRITE_REGION || 'default';
|
||||
const REDIRECT_URL = __ENV.APPWRITE_BENCHMARK_REDIRECT_URL || 'http://localhost';
|
||||
const PASSWORD = __ENV.APPWRITE_BENCHMARK_PASSWORD || 'Password123!';
|
||||
const WORKER_TIMEOUT_MS = Number(__ENV.APPWRITE_WORKER_TIMEOUT_MS || 120000);
|
||||
const ITERATIONS = Number(__ENV.APPWRITE_BENCHMARK_ITERATIONS || 1);
|
||||
const VUS = Number(__ENV.APPWRITE_BENCHMARK_VUS || 1);
|
||||
const SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_SUMMARY_PATH || '/tmp/appwrite-k6-summary.json';
|
||||
const PREVIOUS_SUMMARY_PATH = __ENV.APPWRITE_BENCHMARK_PREVIOUS_SUMMARY_PATH || '';
|
||||
const PREVIOUS_SUMMARY = PREVIOUS_SUMMARY_PATH ? loadPreviousSummary(PREVIOUS_SUMMARY_PATH) : null;
|
||||
|
||||
// you can specify stages of your test (ramp up/down patterns) through the options object
|
||||
// target is the number of VUs you are aiming for
|
||||
export const httpWaiting = new Trend('appwrite_http_waiting', true);
|
||||
export const apiDuration = new Trend('appwrite_api_duration', true);
|
||||
export const apiWaiting = new Trend('appwrite_api_waiting', true);
|
||||
export const flowFailures = new Counter('appwrite_benchmark_flow_failures');
|
||||
|
||||
export const options = {
|
||||
stages: [
|
||||
{ target: 50, duration: '1m' },
|
||||
// { target: 15, duration: '1m' },
|
||||
// { target: 0, duration: '1m' },
|
||||
],
|
||||
scenarios: {
|
||||
curated_flows: {
|
||||
executor: 'shared-iterations',
|
||||
exec: 'curatedFlows',
|
||||
vus: VUS,
|
||||
iterations: ITERATIONS,
|
||||
maxDuration: __ENV.APPWRITE_BENCHMARK_MAX_DURATION || '30m',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
requests: ['count < 100'],
|
||||
http_req_failed: ['rate<0.05'],
|
||||
appwrite_api_duration: ['p(95)<2000'],
|
||||
appwrite_benchmark_flow_failures: ['count<1'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const config = {
|
||||
headers: {
|
||||
'X-Appwrite-Key': '24356eb021863f81eb7dd77c7750304d0464e141cad6e9a8befa1f7d2b066fde190df3dab1e8d2639dbb82ee848da30501424923f4cd80d887ee40ad77ded62763ee489448523f6e39667f290f9a54b2ab8fad131a0bc985e6c0f760015f7f3411e40626c75646bb19d2bb2f7bf2f63130918220a206758cbc48845fd725a695',
|
||||
'X-Appwrite-Project': '60479fe35d95d'
|
||||
}}
|
||||
const API_SCOPES = [
|
||||
'sessions.write',
|
||||
'users.read',
|
||||
'users.write',
|
||||
'teams.read',
|
||||
'teams.write',
|
||||
'databases.read',
|
||||
'databases.write',
|
||||
'collections.read',
|
||||
'collections.write',
|
||||
'tables.read',
|
||||
'tables.write',
|
||||
'attributes.read',
|
||||
'attributes.write',
|
||||
'columns.read',
|
||||
'columns.write',
|
||||
'indexes.read',
|
||||
'indexes.write',
|
||||
'documents.read',
|
||||
'documents.write',
|
||||
'rows.read',
|
||||
'rows.write',
|
||||
'files.read',
|
||||
'files.write',
|
||||
'buckets.read',
|
||||
'buckets.write',
|
||||
'functions.read',
|
||||
'functions.write',
|
||||
'log.read',
|
||||
'log.write',
|
||||
'execution.read',
|
||||
'execution.write',
|
||||
'locale.read',
|
||||
'avatars.read',
|
||||
'rules.read',
|
||||
'rules.write',
|
||||
'migrations.read',
|
||||
'migrations.write',
|
||||
'vcs.read',
|
||||
'vcs.write',
|
||||
'assistant.read',
|
||||
'tokens.read',
|
||||
'tokens.write',
|
||||
'platforms.read',
|
||||
'platforms.write',
|
||||
];
|
||||
|
||||
const resDb = http.get('http://localhost:9501/', config);
|
||||
const BASE_PERMISSIONS = [
|
||||
'read("any")',
|
||||
'create("any")',
|
||||
'update("any")',
|
||||
'delete("any")',
|
||||
];
|
||||
|
||||
check(resDb, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
const ITEM_PERMISSIONS = [
|
||||
'read("any")',
|
||||
'update("any")',
|
||||
'delete("any")',
|
||||
];
|
||||
|
||||
export function setup() {
|
||||
const runId = unique('run');
|
||||
const consoleEmail = __ENV.APPWRITE_ADMIN_EMAIL || `bench-admin-${runId}@example.com`;
|
||||
const consolePassword = __ENV.APPWRITE_ADMIN_PASSWORD || PASSWORD;
|
||||
|
||||
const consoleHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': CONSOLE_PROJECT,
|
||||
};
|
||||
|
||||
const account = rawRequest('POST', '/account', {
|
||||
userId: unique('admin'),
|
||||
email: consoleEmail,
|
||||
password: consolePassword,
|
||||
name: 'Benchmark Admin',
|
||||
}, consoleHeaders, 'setup.account.create');
|
||||
|
||||
if (![201, 409].includes(account.status)) {
|
||||
failResponse(account, 'Unable to create or reuse the benchmark console account');
|
||||
}
|
||||
|
||||
const session = rawRequest('POST', '/account/sessions/email', {
|
||||
email: consoleEmail,
|
||||
password: consolePassword,
|
||||
}, consoleHeaders, 'setup.account.session');
|
||||
|
||||
assertStatus(session, [201], 'console session created');
|
||||
|
||||
const consoleSessionHeaders = {
|
||||
...consoleHeaders,
|
||||
Cookie: cookieHeader(session),
|
||||
};
|
||||
|
||||
const team = setupApi('POST', '/teams', {
|
||||
teamId: unique('team'),
|
||||
name: `Benchmark Team ${runId}`,
|
||||
}, consoleSessionHeaders, [201], 'setup.teams.create');
|
||||
|
||||
const teamId = team.json('$id');
|
||||
const project = setupApi('POST', '/projects', {
|
||||
projectId: unique('project'),
|
||||
name: `Benchmark Project ${runId}`,
|
||||
teamId,
|
||||
region: REGION,
|
||||
}, consoleSessionHeaders, [201], 'setup.projects.create');
|
||||
|
||||
const projectId = project.json('$id');
|
||||
const key = setupApi('POST', `/projects/${projectId}/keys`, {
|
||||
keyId: unique('key'),
|
||||
name: 'Benchmark API key',
|
||||
scopes: API_SCOPES,
|
||||
}, consoleSessionHeaders, [201], 'setup.projects.keys.create');
|
||||
|
||||
const apiHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': projectId,
|
||||
'X-Appwrite-Key': key.json('secret'),
|
||||
};
|
||||
|
||||
const platform = setupApi('POST', '/project/platforms/web', {
|
||||
platformId: unique('web'),
|
||||
name: 'Benchmark web',
|
||||
hostname: hostnameFromUrl(REDIRECT_URL),
|
||||
}, apiHeaders, [201, 409], 'setup.project.platforms.web.create');
|
||||
|
||||
const tablesDb = setupTablesDb(apiHeaders);
|
||||
|
||||
return {
|
||||
runId,
|
||||
teamId,
|
||||
projectId,
|
||||
databaseId: tablesDb.databaseId,
|
||||
tableId: tablesDb.tableId,
|
||||
consoleSessionHeaders,
|
||||
apiHeaders,
|
||||
platformStatus: platform.status,
|
||||
};
|
||||
}
|
||||
|
||||
function setupTablesDb(apiHeaders) {
|
||||
const databaseId = unique('tdb');
|
||||
const tableId = unique('tbl');
|
||||
|
||||
setupApi('POST', '/tablesdb', { databaseId, name: 'Benchmark TablesDB' }, apiHeaders, [201], 'setup.tablesdb.create');
|
||||
setupApi('POST', `/tablesdb/${databaseId}/tables`, {
|
||||
tableId,
|
||||
name: 'Benchmark Table',
|
||||
permissions: BASE_PERMISSIONS,
|
||||
rowSecurity: false,
|
||||
}, apiHeaders, [201], 'setup.tablesdb.tables.create');
|
||||
|
||||
const columns = [
|
||||
['string', 'title', { size: 128 }],
|
||||
['integer', 'quantity', { min: 0, max: 100000 }],
|
||||
['email', 'email', {}],
|
||||
['boolean', 'active', {}],
|
||||
];
|
||||
|
||||
for (const [type, key, extra] of columns) {
|
||||
setupApi('POST', `/tablesdb/${databaseId}/tables/${tableId}/columns/${type}`, {
|
||||
key,
|
||||
required: false,
|
||||
array: false,
|
||||
...extra,
|
||||
}, apiHeaders, [202], `setup.tablesdb.columns.${type}.create`);
|
||||
waitForStatus(`/tablesdb/${databaseId}/tables/${tableId}/columns/${key}`, apiHeaders, 'available', WORKER_TIMEOUT_MS, `setup.tablesdb.columns.${type}.wait`);
|
||||
}
|
||||
|
||||
return { databaseId, tableId };
|
||||
}
|
||||
|
||||
export function curatedFlows(data) {
|
||||
const ctx = { ...data };
|
||||
|
||||
try {
|
||||
group('account flow', () => accountFlow(ctx));
|
||||
group('tablesdb rows flow', () => tablesDbFlow(ctx));
|
||||
group('storage files and tokens flow', () => storageFlow(ctx));
|
||||
group('functions control-plane flow', () => computeFlow(ctx));
|
||||
} catch (error) {
|
||||
flowFailures.add(1);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
if (data && data.projectId && data.consoleSessionHeaders) {
|
||||
rawRequest('DELETE', `/projects/${data.projectId}`, null, data.consoleSessionHeaders, 'teardown.projects.delete');
|
||||
}
|
||||
|
||||
if (data && data.teamId && data.consoleSessionHeaders) {
|
||||
rawRequest('DELETE', `/teams/${data.teamId}`, null, data.consoleSessionHeaders, 'teardown.teams.delete');
|
||||
}
|
||||
}
|
||||
|
||||
function accountFlow(ctx) {
|
||||
const userId = unique('user');
|
||||
const email = `bench-user-${unique('mail')}@example.com`;
|
||||
const headers = projectHeaders(ctx.projectId);
|
||||
|
||||
api('POST', '/account', {
|
||||
userId,
|
||||
email,
|
||||
password: PASSWORD,
|
||||
name: 'Benchmark User',
|
||||
}, headers, [201], 'account.create');
|
||||
|
||||
const session = api('POST', '/account/sessions/email', {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
}, headers, [201], 'account.sessions.email.create');
|
||||
|
||||
const sessionHeaders = {
|
||||
...headers,
|
||||
Cookie: cookieHeader(session),
|
||||
};
|
||||
|
||||
ctx.userId = userId;
|
||||
ctx.userEmail = email;
|
||||
ctx.sessionHeaders = sessionHeaders;
|
||||
|
||||
api('GET', '/account', null, sessionHeaders, [200], 'account.get');
|
||||
api('GET', '/account/logs', null, sessionHeaders, [200], 'account.logs.list');
|
||||
api('PATCH', '/account/prefs', { prefs: { benchmark: true, runId: ctx.runId } }, sessionHeaders, [200], 'account.prefs.update');
|
||||
api('PATCH', '/account/name', { name: 'Benchmark User Updated' }, sessionHeaders, [200], 'account.name.update');
|
||||
api('PATCH', '/account/password', { password: `${PASSWORD}2`, oldPassword: PASSWORD }, sessionHeaders, [200], 'account.password.update');
|
||||
}
|
||||
|
||||
function tablesDbFlow(ctx) {
|
||||
requireSession(ctx, 'tablesDbFlow');
|
||||
|
||||
const databaseId = ctx.databaseId;
|
||||
const tableId = ctx.tableId;
|
||||
const rowId = unique('row');
|
||||
|
||||
api('POST', `/tablesdb/${databaseId}/tables/${tableId}/rows`, {
|
||||
rowId,
|
||||
data: tablePayload(),
|
||||
permissions: ITEM_PERMISSIONS,
|
||||
}, ctx.sessionHeaders, [201], 'tablesdb.rows.create');
|
||||
api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.list');
|
||||
api('GET', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [200], 'tablesdb.rows.get');
|
||||
api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, {
|
||||
data: { title: 'Benchmark Row Updated' },
|
||||
}, ctx.sessionHeaders, [200], 'tablesdb.rows.update');
|
||||
api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/increment`, {
|
||||
value: 1,
|
||||
}, ctx.sessionHeaders, [200], 'tablesdb.rows.increment');
|
||||
api('PATCH', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}/quantity/decrement`, {
|
||||
value: 1,
|
||||
}, ctx.sessionHeaders, [200], 'tablesdb.rows.decrement');
|
||||
api('DELETE', `/tablesdb/${databaseId}/tables/${tableId}/rows/${rowId}`, null, ctx.sessionHeaders, [204], 'tablesdb.rows.delete');
|
||||
}
|
||||
|
||||
function storageFlow(ctx) {
|
||||
requireSession(ctx, 'storageFlow');
|
||||
|
||||
const bucketId = unique('bucket');
|
||||
const fileId = unique('file');
|
||||
|
||||
api('POST', '/storage/buckets', {
|
||||
bucketId,
|
||||
name: 'Benchmark Bucket',
|
||||
permissions: BASE_PERMISSIONS,
|
||||
fileSecurity: false,
|
||||
enabled: true,
|
||||
maximumFileSize: 30000000,
|
||||
allowedFileExtensions: [],
|
||||
compression: 'none',
|
||||
encryption: false,
|
||||
antivirus: false,
|
||||
}, ctx.apiHeaders, [201], 'storage.buckets.create');
|
||||
|
||||
const multipartHeaders = { ...ctx.sessionHeaders };
|
||||
delete multipartHeaders['Content-Type'];
|
||||
|
||||
const upload = http.post(`${ENDPOINT}/storage/buckets/${bucketId}/files`, {
|
||||
fileId,
|
||||
file: http.file(onePixelPng(), 'benchmark.png', 'image/png'),
|
||||
...flattenMultipartArray('permissions', ITEM_PERMISSIONS),
|
||||
}, {
|
||||
headers: multipartHeaders,
|
||||
tags: { name: 'storage.files.create' },
|
||||
});
|
||||
}
|
||||
|
||||
httpWaiting.add(upload.timings.waiting, { name: 'storage.files.create' });
|
||||
apiDuration.add(upload.timings.duration, { name: 'storage.files.create' });
|
||||
apiWaiting.add(upload.timings.waiting, { name: 'storage.files.create' });
|
||||
assertStatus(upload, [201], 'storage file created');
|
||||
|
||||
api('GET', `/storage/buckets/${bucketId}/files`, null, ctx.sessionHeaders, [200], 'storage.files.list');
|
||||
api('GET', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [200], 'storage.files.get');
|
||||
api('GET', `/storage/buckets/${bucketId}/files/${fileId}/view`, null, ctx.sessionHeaders, [200], 'storage.files.view');
|
||||
api('GET', `/storage/buckets/${bucketId}/files/${fileId}/download`, null, ctx.sessionHeaders, [200], 'storage.files.download');
|
||||
api('GET', `/storage/buckets/${bucketId}/files/${fileId}/preview`, null, ctx.sessionHeaders, [200], 'storage.files.preview');
|
||||
api('PUT', `/storage/buckets/${bucketId}/files/${fileId}`, {
|
||||
name: 'benchmark-renamed.png',
|
||||
permissions: ITEM_PERMISSIONS,
|
||||
}, ctx.sessionHeaders, [200], 'storage.files.update');
|
||||
|
||||
const token = api('POST', `/tokens/buckets/${bucketId}/files/${fileId}`, {}, ctx.apiHeaders, [201], 'tokens.files.create');
|
||||
api('GET', `/tokens/buckets/${bucketId}/files/${fileId}`, null, ctx.apiHeaders, [200], 'tokens.files.list');
|
||||
api('GET', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [200], 'tokens.get');
|
||||
api('PATCH', `/tokens/${token.json('$id')}`, { expire: null }, ctx.apiHeaders, [200], 'tokens.update');
|
||||
api('DELETE', `/tokens/${token.json('$id')}`, null, ctx.apiHeaders, [204], 'tokens.delete');
|
||||
|
||||
api('DELETE', `/storage/buckets/${bucketId}/files/${fileId}`, null, ctx.sessionHeaders, [204], 'storage.files.delete');
|
||||
api('DELETE', `/storage/buckets/${bucketId}`, null, ctx.apiHeaders, [204], 'storage.buckets.delete');
|
||||
}
|
||||
|
||||
function computeFlow(ctx) {
|
||||
requireSession(ctx, 'computeFlow');
|
||||
|
||||
const functionId = unique('fn');
|
||||
let functionVariableId;
|
||||
|
||||
api('POST', '/functions', {
|
||||
functionId,
|
||||
name: 'Benchmark Function',
|
||||
runtime: __ENV.APPWRITE_BENCHMARK_RUNTIME || 'node-22',
|
||||
execute: ['any'],
|
||||
events: [],
|
||||
schedule: '',
|
||||
timeout: 15,
|
||||
enabled: true,
|
||||
logging: true,
|
||||
entrypoint: 'index.js',
|
||||
commands: 'npm install',
|
||||
scopes: ['users.read'],
|
||||
}, ctx.apiHeaders, [201], 'functions.create');
|
||||
api('GET', '/functions/runtimes', null, ctx.sessionHeaders, [200], 'functions.runtimes.list');
|
||||
api('GET', '/functions/specifications', null, ctx.apiHeaders, [200], 'functions.specifications.list');
|
||||
const functionVariable = api('POST', `/functions/${functionId}/variables`, {
|
||||
key: 'BENCHMARK',
|
||||
value: 'true',
|
||||
secret: false,
|
||||
}, ctx.apiHeaders, [201], 'functions.variables.create');
|
||||
functionVariableId = functionVariable.json('$id');
|
||||
|
||||
api('PUT', `/functions/${functionId}/variables/${functionVariableId}`, {
|
||||
key: 'BENCHMARK',
|
||||
value: 'updated',
|
||||
secret: false,
|
||||
}, ctx.apiHeaders, [200], 'functions.variables.update');
|
||||
api('GET', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [200], 'functions.variables.get');
|
||||
api('DELETE', `/functions/${functionId}/variables/${functionVariableId}`, null, ctx.apiHeaders, [204], 'functions.variables.delete');
|
||||
api('DELETE', `/functions/${functionId}`, null, ctx.apiHeaders, [204], 'functions.delete');
|
||||
}
|
||||
|
||||
function api(method, path, body, headers, expected, name) {
|
||||
const response = rawRequest(method, path, body, headers, name);
|
||||
apiDuration.add(response.timings.duration, { name });
|
||||
apiWaiting.add(response.timings.waiting, { name });
|
||||
assertStatus(response, expected, name);
|
||||
return response;
|
||||
}
|
||||
|
||||
function setupApi(method, path, body, headers, expected, name) {
|
||||
const response = rawRequest(method, path, body, headers, name);
|
||||
assertStatus(response, expected, name);
|
||||
return response;
|
||||
}
|
||||
|
||||
function rawRequest(method, path, body, headers, name) {
|
||||
const params = {
|
||||
headers,
|
||||
tags: { name },
|
||||
};
|
||||
const payload = body === null || body === undefined ? null : JSON.stringify(body);
|
||||
const response = http.request(method, `${ENDPOINT}${path}`, payload, params);
|
||||
httpWaiting.add(response.timings.waiting, { name });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function waitForStatus(path, headers, wantedStatus, timeoutMs, name) {
|
||||
const started = Date.now();
|
||||
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const response = rawRequest('GET', path, null, headers, name);
|
||||
if (response.status === 200) {
|
||||
const status = response.json('status');
|
||||
if (status === wantedStatus) {
|
||||
return response;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
throw new Error(`${path} failed while waiting for ${wantedStatus}`);
|
||||
}
|
||||
}
|
||||
sleep(0.5);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${path} to become ${wantedStatus}`);
|
||||
}
|
||||
|
||||
function assertStatus(response, expected, name) {
|
||||
const ok = check(response, {
|
||||
[`${name} status ${expected.join('|')}`]: (r) => expected.includes(r.status),
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
failResponse(response, `${name} returned an unexpected status`);
|
||||
}
|
||||
}
|
||||
|
||||
function failResponse(response, message) {
|
||||
throw new Error(`${message}. Status: ${response.status}. Body: ${response.body}`);
|
||||
}
|
||||
|
||||
function cookieHeader(response) {
|
||||
return response.headers['Set-Cookie'] || response.headers['set-cookie'] || '';
|
||||
}
|
||||
|
||||
function projectHeaders(projectId) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function requireSession(ctx, flow) {
|
||||
if (!ctx.sessionHeaders || typeof ctx.sessionHeaders !== 'object') {
|
||||
throw new Error(`accountFlow must run before ${flow}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tablePayload() {
|
||||
return {
|
||||
title: 'Benchmark Row',
|
||||
quantity: 1,
|
||||
email: 'row@example.com',
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function onePixelPng() {
|
||||
return encoding.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=', 'std', 'b');
|
||||
}
|
||||
|
||||
function flattenMultipartArray(key, values) {
|
||||
const output = {};
|
||||
values.forEach((value, index) => {
|
||||
output[`${key}[${index}]`] = value;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function unique(prefix) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.slice(0, 36);
|
||||
}
|
||||
|
||||
function hostnameFromUrl(value) {
|
||||
return value.replace(/^https?:\/\//, '').split('/')[0].split(':')[0];
|
||||
}
|
||||
|
||||
export function handleSummary(data) {
|
||||
const lines = [
|
||||
'Appwrite curated benchmark review',
|
||||
'',
|
||||
'Before',
|
||||
'',
|
||||
summaryTable(PREVIOUS_SUMMARY),
|
||||
'',
|
||||
'After',
|
||||
'',
|
||||
summaryTable(data),
|
||||
'',
|
||||
'Delta',
|
||||
'',
|
||||
deltaTable(PREVIOUS_SUMMARY, data),
|
||||
'',
|
||||
];
|
||||
|
||||
return {
|
||||
stdout: `${lines.join('\n')}\n`,
|
||||
[SUMMARY_PATH]: JSON.stringify(data, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function summaryTable(data) {
|
||||
return [
|
||||
'| Scenario | P50 (ms) | P95 (ms) | Requests | RPS |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
summaryRow(data, 'API total', 'appwrite_api_duration'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summaryRow(data, label, metric, iterationsMetric = null, rpsMetric = null) {
|
||||
const values = data && data.metrics[metric] && data.metrics[metric].values;
|
||||
if (!values || values.count === 0) {
|
||||
return `| ${label} | n/a | n/a | n/a | n/a |`;
|
||||
}
|
||||
|
||||
const iterations = iterationsMetric
|
||||
? trendMetric(data, iterationsMetric, 'count')
|
||||
: values.count;
|
||||
const rps = rpsMetric ? trendMetric(data, rpsMetric, 'rate') : null;
|
||||
|
||||
return `| ${label} | ${formatDetailValue(values.med)} | ${formatDetailValue(values['p(95)'])} | ${formatCount(iterations)} | ${formatRate(rps)} |`;
|
||||
}
|
||||
|
||||
function loadPreviousSummary(path) {
|
||||
let contents;
|
||||
try {
|
||||
contents = open(path);
|
||||
} catch (error) {
|
||||
console.warn(`Missing benchmark summary at ${path}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(contents);
|
||||
} catch (error) {
|
||||
console.warn(`Invalid benchmark summary at ${path}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deltaTable(before, after) {
|
||||
return [
|
||||
'| Scenario | P95 delta (ms) |',
|
||||
'| --- | ---: |',
|
||||
...[
|
||||
['API total', 'appwrite_api_duration'],
|
||||
].map(([label, metric]) => {
|
||||
const beforeP95 = trendMetric(before, metric, 'p(95)');
|
||||
const afterP95 = trendMetric(after, metric, 'p(95)');
|
||||
return `| ${label} | ${formatDelta(beforeP95, afterP95)} |`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function trendMetric(data, metric, stat) {
|
||||
return data && data.metrics[metric] && data.metrics[metric].values
|
||||
? data.metrics[metric].values[stat]
|
||||
: null;
|
||||
}
|
||||
|
||||
function formatDetailValue(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDelta(before, after) {
|
||||
if (before === null || before === undefined || after === null || after === undefined || Number.isNaN(before) || Number.isNaN(after)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const delta = round(after - before);
|
||||
const sign = delta > 0 ? '+' : '';
|
||||
return `${sign}${delta}`;
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Number(value).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round((value || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
@@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => true,
|
||||
@@ -3695,6 +3696,7 @@ class AccountCustomClientTest extends Scope
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => false,
|
||||
|
||||
@@ -567,6 +567,44 @@ class FunctionsCustomServerTest extends Scope
|
||||
}, 120000, 500);
|
||||
}
|
||||
|
||||
public function testCreateDeploymentWithSingleContentRangeChunk(): void
|
||||
{
|
||||
$functionId = $this->setupFunction([
|
||||
'functionId' => ID::unique(),
|
||||
'name' => 'Test Single Chunk Range',
|
||||
'execute' => [Role::user($this->getUser()['$id'])->toString()],
|
||||
'runtime' => 'node-22',
|
||||
'entrypoint' => 'index.js',
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$code = $this->packageFunction('basic');
|
||||
$size = \filesize($code->getFilename());
|
||||
|
||||
$deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([
|
||||
'content-type' => 'multipart/form-data',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size,
|
||||
], $this->getHeaders()), [
|
||||
'code' => $code,
|
||||
'activate' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $deployment['headers']['status-code']);
|
||||
$this->assertNotEmpty($deployment['body']['$id']);
|
||||
|
||||
$deploymentId = $deployment['body']['$id'];
|
||||
|
||||
$this->assertEventually(function () use ($functionId, $deploymentId) {
|
||||
$deployment = $this->getDeployment($functionId, $deploymentId);
|
||||
|
||||
$this->assertEquals(200, $deployment['headers']['status-code']);
|
||||
$this->assertEquals('ready', $deployment['body']['status']);
|
||||
}, 120000, 500);
|
||||
|
||||
$this->cleanupFunction($functionId);
|
||||
}
|
||||
|
||||
public function testCreateFunctionAndDeploymentFromTemplate()
|
||||
{
|
||||
|
||||
|
||||
@@ -4207,7 +4207,9 @@ trait MigrationsBase
|
||||
}, 30_000, 500);
|
||||
|
||||
// Check that email was sent with download link
|
||||
$lastEmail = $this->getLastEmail();
|
||||
$lastEmail = $this->getLastEmail(probe: function ($email) {
|
||||
$this->assertEquals('Your JSON export is ready', $email['subject']);
|
||||
});
|
||||
$this->assertNotEmpty($lastEmail);
|
||||
$this->assertEquals('Your JSON export is ready', $lastEmail['subject']);
|
||||
$this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
|
||||
trait AuthMethodsBase
|
||||
{
|
||||
/**
|
||||
* methodId => response field name exposed by the Project model.
|
||||
*/
|
||||
protected static array $authMethods = [
|
||||
'email-password' => 'authEmailPassword',
|
||||
'magic-url' => 'authUsersAuthMagicURL',
|
||||
'email-otp' => 'authEmailOtp',
|
||||
'anonymous' => 'authAnonymous',
|
||||
'invites' => 'authInvites',
|
||||
'jwt' => 'authJWT',
|
||||
'phone' => 'authPhone',
|
||||
];
|
||||
|
||||
// Success flow
|
||||
|
||||
public function testDisableAuthMethod(): void
|
||||
{
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$response = $this->updateAuthMethod($methodId, false);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(false, $response['body'][$responseKey]);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->updateAuthMethod($methodId, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testEnableAuthMethod(): void
|
||||
{
|
||||
// Disable first
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->updateAuthMethod($methodId, false);
|
||||
}
|
||||
|
||||
// Re-enable
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$response = $this->updateAuthMethod($methodId, true);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(true, $response['body'][$responseKey]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDisableAuthMethodIdempotent(): void
|
||||
{
|
||||
$first = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $first['headers']['status-code']);
|
||||
$this->assertSame(false, $first['body']['authEmailPassword']);
|
||||
|
||||
$second = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $second['headers']['status-code']);
|
||||
$this->assertSame(false, $second['body']['authEmailPassword']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testEnableAuthMethodIdempotent(): void
|
||||
{
|
||||
$first = $this->updateAuthMethod('email-password', true);
|
||||
$this->assertSame(200, $first['headers']['status-code']);
|
||||
$this->assertSame(true, $first['body']['authEmailPassword']);
|
||||
|
||||
$second = $this->updateAuthMethod('email-password', true);
|
||||
$this->assertSame(200, $second['headers']['status-code']);
|
||||
$this->assertSame(true, $second['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testDisableOneMethodDoesNotAffectOther(): void
|
||||
{
|
||||
// Ensure both start enabled
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
$this->updateAuthMethod('magic-url', true);
|
||||
|
||||
$response = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
$this->assertSame(true, $response['body']['authUsersAuthMagicURL']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testDisabledEmailPasswordBlocksSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('email-password', false);
|
||||
|
||||
// Unauthenticated account creation would normally be permitted; with the
|
||||
// method disabled we expect the shared auth filter to reject it.
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => 'unique()',
|
||||
'email' => 'disabled-method-' . \uniqid() . '@appwrite.io',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertSame(501, $response['headers']['status-code']);
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testEnabledEmailPasswordAllowsSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('email-password', false);
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => 'unique()',
|
||||
'email' => 'enabled-method-' . \uniqid() . '@appwrite.io',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertNotSame(501, $response['headers']['status-code']);
|
||||
$this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '');
|
||||
}
|
||||
|
||||
public function testDisabledAnonymousBlocksSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('anonymous', false);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
]);
|
||||
|
||||
$this->assertSame(501, $response['headers']['status-code']);
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('anonymous', true);
|
||||
}
|
||||
|
||||
public function testResponseModel(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('email-password', false);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertArrayHasKey('$id', $response['body']);
|
||||
$this->assertArrayHasKey('name', $response['body']);
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->assertArrayHasKey($responseKey, $response['body']);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
// Failure flow
|
||||
|
||||
public function testUpdateAuthMethodWithoutAuthentication(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('email-password', false, false);
|
||||
|
||||
$this->assertSame(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodInvalidMethodId(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('invalid-method', false);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodEmptyMethodId(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('', false);
|
||||
|
||||
$this->assertSame(404, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodMissingEnabled(): void
|
||||
{
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders());
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/email-password',
|
||||
$headers,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
|
||||
public function testUpdateAuthMethodLegacyAliasPath(): void
|
||||
{
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
// Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'enabled' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
// Re-enable via the legacy alias
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'enabled' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(true, $response['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodLegacyStatusParam(): void
|
||||
{
|
||||
// Old SDK passed `status` in the body. The V23 request filter (triggered
|
||||
// via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`.
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'status' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'status' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(true, $response['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodLegacyMethodParam(): void
|
||||
{
|
||||
// Old SDK also had `method` as a path identifier; the V23 filter renames
|
||||
// a stray `method` body field to `methodId`. The URL path parameter of
|
||||
// the alias already binds to `:methodId`, so supplying `method` in the
|
||||
// body is tolerated.
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'method' => 'email-password',
|
||||
'status' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed
|
||||
{
|
||||
$headers = [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
];
|
||||
|
||||
if ($authenticated) {
|
||||
$headers = \array_merge($headers, $this->getHeaders());
|
||||
}
|
||||
|
||||
return $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/' . $methodId,
|
||||
$headers,
|
||||
[
|
||||
'enabled' => $enabled,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideConsole;
|
||||
|
||||
class AuthMethodsConsoleClientTest extends Scope
|
||||
{
|
||||
use AuthMethodsBase;
|
||||
use ProjectCustom;
|
||||
use SideConsole;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
|
||||
class AuthMethodsCustomServerTest extends Scope
|
||||
{
|
||||
use AuthMethodsBase;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
|
||||
class AuthMethodsIntegrationTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
public function testAuthMethodsIntegration(): void
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$apiKey = $this->getProject()['apiKey'];
|
||||
|
||||
$serverHeaders = [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $apiKey,
|
||||
];
|
||||
|
||||
// Public headers carry no session / api key — this forces the shared
|
||||
// auth init to actually evaluate the auth-method gate (it is bypassed
|
||||
// for privileged / app users).
|
||||
$publicHeaders = [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
];
|
||||
|
||||
$setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void {
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/' . $methodId,
|
||||
$serverHeaders,
|
||||
['enabled' => $enabled]
|
||||
);
|
||||
$this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId);
|
||||
};
|
||||
|
||||
$methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
|
||||
|
||||
// Step 1 — Disable every auth method up front.
|
||||
foreach ($methods as $methodId) {
|
||||
$setAuthMethod($methodId, false);
|
||||
}
|
||||
|
||||
$assertBlocked = function (array $response, string $context): void {
|
||||
$this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501');
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported');
|
||||
};
|
||||
|
||||
$assertNotBlocked = function (array $response, string $context): void {
|
||||
$this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling');
|
||||
$this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling');
|
||||
};
|
||||
|
||||
$email = 'auth_methods_' . \uniqid() . '@localhost.test';
|
||||
$password = 'password1234';
|
||||
|
||||
// Step 2 — anonymous session creation.
|
||||
$anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders);
|
||||
|
||||
$assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)');
|
||||
$setAuthMethod('anonymous', true);
|
||||
$response = $anonymousAttempt();
|
||||
$assertNotBlocked($response, 'Anonymous session (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 3 — email/password account creation.
|
||||
$createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'name' => 'Auth Methods User',
|
||||
]);
|
||||
|
||||
$assertBlocked($createAccount(), 'Account creation (email-password disabled)');
|
||||
$setAuthMethod('email-password', true);
|
||||
$response = $createAccount();
|
||||
$assertNotBlocked($response, 'Account creation (email-password enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$userId = $response['body']['$id'];
|
||||
|
||||
// Step 4 — email/password session creation (still gated by email-password).
|
||||
// Disable momentarily to prove the session endpoint is gated too.
|
||||
$setAuthMethod('email-password', false);
|
||||
$emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)');
|
||||
$setAuthMethod('email-password', true);
|
||||
$response = $emailSessionAttempt();
|
||||
$assertNotBlocked($response, 'Email/password session (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$sessionSecret = $response['cookies']['a_session_' . $projectId] ?? '';
|
||||
$this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login');
|
||||
|
||||
// Step 5 — email OTP token.
|
||||
$emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [
|
||||
'userId' => $userId,
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
$assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)');
|
||||
$setAuthMethod('email-otp', true);
|
||||
$response = $emailOtpAttempt();
|
||||
$assertNotBlocked($response, 'Email OTP (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 6 — magic URL token.
|
||||
$magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'email' => 'magic_' . \uniqid() . '@localhost.test',
|
||||
]);
|
||||
|
||||
$assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)');
|
||||
$setAuthMethod('magic-url', true);
|
||||
$response = $magicUrlAttempt();
|
||||
$assertNotBlocked($response, 'Magic URL (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 7 — phone token. After enabling the auth method the endpoint may
|
||||
// still fail for provider reasons — we only assert that the auth-method
|
||||
// gate stops fighting us.
|
||||
$phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'phone' => '+14155550199',
|
||||
]);
|
||||
|
||||
$assertBlocked($phoneAttempt(), 'Phone token (disabled)');
|
||||
$setAuthMethod('phone', true);
|
||||
$assertNotBlocked($phoneAttempt(), 'Phone token (enabled)');
|
||||
|
||||
// Step 8 — team invites. Needs an existing team; the session user
|
||||
// isn't a team owner, so we don't assert on 201 here — the gate itself
|
||||
// is what's under test and any non-501 proves it was lifted.
|
||||
$teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [
|
||||
'teamId' => ID::unique(),
|
||||
'name' => 'Auth Methods Team',
|
||||
]);
|
||||
$this->assertSame(201, $teamResponse['headers']['status-code']);
|
||||
$teamId = $teamResponse['body']['$id'];
|
||||
|
||||
$inviteHeaders = \array_merge($publicHeaders, [
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
|
||||
]);
|
||||
$inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [
|
||||
'email' => 'invitee_' . \uniqid() . '@localhost.test',
|
||||
'roles' => ['developer'],
|
||||
'url' => 'http://localhost/join',
|
||||
]);
|
||||
|
||||
$assertBlocked($inviteAttempt(), 'Team invite (disabled)');
|
||||
$setAuthMethod('invites', true);
|
||||
$assertNotBlocked($inviteAttempt(), 'Team invite (enabled)');
|
||||
|
||||
// Step 9 — JWT creation. Requires an active session.
|
||||
$sessionHeaders = \array_merge($publicHeaders, [
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
|
||||
]);
|
||||
$jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders);
|
||||
|
||||
$assertBlocked($jwtAttempt(), 'JWT (disabled)');
|
||||
$setAuthMethod('jwt', true);
|
||||
$response = $jwtAttempt();
|
||||
$assertNotBlocked($response, 'JWT (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 10 — End goal: GET /v1/account returns 200 using the session we
|
||||
// built via the (now enabled) email-password flow.
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame($userId, $response['body']['$id']);
|
||||
$this->assertSame($email, $response['body']['email']);
|
||||
}
|
||||
}
|
||||
@@ -1764,6 +1764,7 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders()), [
|
||||
'status' => false,
|
||||
]);
|
||||
@@ -1860,6 +1861,7 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders()), [
|
||||
'status' => true,
|
||||
]);
|
||||
|
||||
@@ -866,6 +866,46 @@ class SitesCustomServerTest extends Scope
|
||||
// // TODO: Implement testCreateDeploymentFromCLI() later
|
||||
// }
|
||||
|
||||
public function testCreateDeploymentWithSingleContentRangeChunk(): void
|
||||
{
|
||||
$siteId = $this->setupSite([
|
||||
'buildRuntime' => 'node-22',
|
||||
'fallbackFile' => '',
|
||||
'framework' => 'other',
|
||||
'name' => 'Test Site Single Chunk Range',
|
||||
'outputDirectory' => './',
|
||||
'providerBranch' => 'main',
|
||||
'providerRootDirectory' => './',
|
||||
'siteId' => ID::unique()
|
||||
]);
|
||||
|
||||
$code = $this->packageSite('static-single-file');
|
||||
$size = \filesize($code->getFilename());
|
||||
|
||||
$deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([
|
||||
'content-type' => 'multipart/form-data',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size,
|
||||
], $this->getHeaders()), [
|
||||
'code' => $code,
|
||||
'activate' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(202, $deployment['headers']['status-code']);
|
||||
$this->assertNotEmpty($deployment['body']['$id']);
|
||||
|
||||
$deploymentId = $deployment['body']['$id'];
|
||||
|
||||
$this->assertEventually(function () use ($siteId, $deploymentId) {
|
||||
$deployment = $this->getDeployment($siteId, $deploymentId);
|
||||
|
||||
$this->assertEquals(200, $deployment['headers']['status-code']);
|
||||
$this->assertEquals('ready', $deployment['body']['status']);
|
||||
}, 120000, 500);
|
||||
|
||||
$this->cleanupSite($siteId);
|
||||
}
|
||||
|
||||
public function testCreateDeployment()
|
||||
{
|
||||
$siteId = $this->setupSite([
|
||||
|
||||
Reference in New Issue
Block a user