Skip to content

Commit de978eb

Browse files
author
niuweili
committed
ci: upload openapi json to oss
1 parent d8d8a64 commit de978eb

4 files changed

Lines changed: 370 additions & 1 deletion

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
name: Upload OpenAPI JSON to OSS
2+
3+
on:
4+
push:
5+
branches: [main, test]
6+
paths:
7+
- 'api-reference/*.json'
8+
- 'integration-docs/scripts/upload-openapi.mjs'
9+
- 'integration-docs/scripts/upload-openapi.test.mjs'
10+
- 'integration-docs/package.json'
11+
- '.github/workflows/openapi-json-upload.yml'
12+
workflow_dispatch:
13+
inputs:
14+
environment:
15+
description: 'Target environment'
16+
required: true
17+
default: 'development'
18+
type: choice
19+
options:
20+
- development
21+
- production
22+
- both
23+
24+
jobs:
25+
test-openapi-upload:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@v4
30+
31+
- name: Setup Node.js
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: 18
35+
36+
- name: Install dependencies
37+
working-directory: integration-docs
38+
run: npm install
39+
40+
- name: Test OpenAPI upload script
41+
working-directory: integration-docs
42+
run: npm run test:openapi-upload
43+
44+
upload-development:
45+
needs: test-openapi-upload
46+
if: github.ref == 'refs/heads/test' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.environment == 'development' || github.event.inputs.environment == 'both'))
47+
runs-on: ubuntu-latest
48+
environment: development
49+
steps:
50+
- name: Checkout
51+
uses: actions/checkout@v4
52+
53+
- name: Setup Node.js
54+
uses: actions/setup-node@v4
55+
with:
56+
node-version: 18
57+
58+
- name: Install dependencies
59+
working-directory: integration-docs
60+
run: npm install
61+
62+
- name: Upload development OpenAPI JSON
63+
working-directory: integration-docs
64+
env:
65+
CDN_ACCESS_KEY: ${{ secrets.CDN_ACCESS_KEY }}
66+
CDN_SECRET_KEY: ${{ secrets.CDN_SECRET_KEY }}
67+
CDN_BUCKET: ${{ secrets.CDN_BUCKET }}
68+
CDN_REGION: ${{ secrets.CDN_REGION }}
69+
CDN_ENDPOINT: ${{ secrets.CDN_ENDPOINT }}
70+
CDN_URL: ${{ secrets.CDN_URL }}
71+
CDN_DIR: '/test/docs'
72+
run: npm run upload:openapi
73+
74+
upload-production:
75+
needs: test-openapi-upload
76+
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.environment == 'production' || github.event.inputs.environment == 'both'))
77+
runs-on: ubuntu-latest
78+
environment: production
79+
steps:
80+
- name: Checkout
81+
uses: actions/checkout@v4
82+
83+
- name: Setup Node.js
84+
uses: actions/setup-node@v4
85+
with:
86+
node-version: 18
87+
88+
- name: Install dependencies
89+
working-directory: integration-docs
90+
run: npm install
91+
92+
- name: Upload production OpenAPI JSON
93+
working-directory: integration-docs
94+
env:
95+
CDN_ACCESS_KEY: ${{ secrets.CDN_ACCESS_KEY }}
96+
CDN_SECRET_KEY: ${{ secrets.CDN_SECRET_KEY }}
97+
CDN_BUCKET: ${{ secrets.CDN_BUCKET }}
98+
CDN_REGION: ${{ secrets.CDN_REGION }}
99+
CDN_ENDPOINT: ${{ secrets.CDN_ENDPOINT }}
100+
CDN_URL: ${{ secrets.CDN_URL }}
101+
CDN_DIR: '/docs'
102+
run: npm run upload:openapi

integration-docs/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
"scripts": {
1010
"build": "node scripts/build.mjs",
1111
"check": "node scripts/check.mjs",
12-
"upload": "npm run build && node scripts/upload.mjs"
12+
"upload": "npm run build && node scripts/upload.mjs",
13+
"test:openapi-upload": "node --test scripts/upload-openapi.test.mjs",
14+
"upload:openapi": "node scripts/upload-openapi.mjs"
1315
},
1416
"exports": {
1517
"./zh": {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath, pathToFileURL } from 'node:url';
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
const packageRoot = path.resolve(__dirname, '..');
7+
const repoRoot = path.resolve(packageRoot, '..');
8+
const defaultApiReferenceDir = path.join(repoRoot, 'api-reference');
9+
10+
export const requiredEnv = [
11+
'CDN_ACCESS_KEY',
12+
'CDN_SECRET_KEY',
13+
'CDN_BUCKET',
14+
'CDN_REGION',
15+
'CDN_ENDPOINT',
16+
'CDN_URL',
17+
'CDN_DIR'
18+
];
19+
20+
export function validateRequiredEnv(env = process.env) {
21+
return requiredEnv.filter((key) => !env[key]);
22+
}
23+
24+
export function listOpenapiJsonFiles(apiReferenceDir = defaultApiReferenceDir) {
25+
if (!fs.existsSync(apiReferenceDir)) {
26+
throw new Error(`OpenAPI directory does not exist: ${apiReferenceDir}`);
27+
}
28+
29+
return fs.readdirSync(apiReferenceDir, { withFileTypes: true })
30+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
31+
.map((entry) => entry.name)
32+
.sort();
33+
}
34+
35+
function normalizeCdnDir(cdnDir) {
36+
const normalized = cdnDir.replace(/\/+$/g, '');
37+
return normalized || '/';
38+
}
39+
40+
export function buildOssFilePath(cdnDir, file) {
41+
return path.posix.join(normalizeCdnDir(cdnDir), 'api-reference', file);
42+
}
43+
44+
export function buildCdnUrl(ossUrl, cdnEndpoint, cdnUrl) {
45+
const endpointHost = cdnEndpoint
46+
.replace(/^https?:\/\//, '')
47+
.replace(/\/+$/g, '');
48+
const normalizedCdnUrl = cdnUrl.replace(/\/+$/g, '');
49+
const parsedUrl = new URL(ossUrl);
50+
51+
if (parsedUrl.host === endpointHost) {
52+
return `${normalizedCdnUrl}${parsedUrl.pathname}`;
53+
}
54+
55+
return ossUrl.replace(cdnEndpoint, normalizedCdnUrl);
56+
}
57+
58+
export function validateJsonFile(filePath) {
59+
JSON.parse(fs.readFileSync(filePath, 'utf8'));
60+
}
61+
62+
async function createOssClient(env = process.env) {
63+
const { default: OSS } = await import('ali-oss');
64+
return new OSS({
65+
region: env.CDN_REGION,
66+
accessKeyId: env.CDN_ACCESS_KEY,
67+
accessKeySecret: env.CDN_SECRET_KEY,
68+
bucket: env.CDN_BUCKET
69+
});
70+
}
71+
72+
async function createCdnRuntime(env = process.env) {
73+
const { default: CDN } = await import('@alicloud/cdn20180510');
74+
const { default: OpenApi } = await import('@alicloud/openapi-client');
75+
const client = new CDN.default(new OpenApi.Config({
76+
accessKeyId: env.CDN_ACCESS_KEY,
77+
accessKeySecret: env.CDN_SECRET_KEY,
78+
endpoint: 'cdn.aliyuncs.com',
79+
regionId: 'cn-beijing'
80+
}));
81+
82+
return { CDN, client };
83+
}
84+
85+
async function refreshCdnCache(cdnRuntime, url) {
86+
const request = new cdnRuntime.CDN.RefreshObjectCachesRequest({});
87+
request.objectPath = url;
88+
request.objectType = 'File';
89+
await cdnRuntime.client.refreshObjectCaches(request);
90+
console.log(`Refreshed CDN cache: ${url}`);
91+
}
92+
93+
export async function uploadOpenapiJsonFiles({
94+
apiReferenceDir = defaultApiReferenceDir,
95+
env = process.env,
96+
ossClient,
97+
cdnRuntime
98+
} = {}) {
99+
const missing = validateRequiredEnv(env);
100+
if (missing.length > 0) {
101+
throw new Error(`Missing required env vars: ${missing.join(', ')}`);
102+
}
103+
104+
const files = listOpenapiJsonFiles(apiReferenceDir);
105+
if (files.length === 0) {
106+
throw new Error(`No OpenAPI JSON files found in ${apiReferenceDir}`);
107+
}
108+
109+
for (const file of files) {
110+
validateJsonFile(path.join(apiReferenceDir, file));
111+
}
112+
113+
const resolvedOssClient = ossClient ?? await createOssClient(env);
114+
const resolvedCdnRuntime = cdnRuntime ?? await createCdnRuntime(env);
115+
116+
for (const file of files) {
117+
const localFilePath = path.join(apiReferenceDir, file);
118+
const ossFilePath = buildOssFilePath(env.CDN_DIR, file);
119+
const result = await resolvedOssClient.put(ossFilePath, localFilePath, {
120+
headers: {
121+
'Content-Type': 'application/json; charset=utf-8',
122+
'Cache-Control': 'public, max-age=300'
123+
}
124+
});
125+
const cdnUrl = buildCdnUrl(result.url, env.CDN_ENDPOINT, env.CDN_URL);
126+
console.log(`Uploaded ${file} -> ${cdnUrl}`);
127+
await refreshCdnCache(resolvedCdnRuntime, cdnUrl);
128+
}
129+
130+
console.log(`Uploaded ${files.length} OpenAPI JSON files from ${apiReferenceDir}`);
131+
}
132+
133+
async function loadDotenvIfAvailable() {
134+
try {
135+
const { default: dotenv } = await import('dotenv');
136+
dotenv.config();
137+
} catch (err) {
138+
if (err.code !== 'ERR_MODULE_NOT_FOUND') {
139+
throw err;
140+
}
141+
}
142+
}
143+
144+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
145+
await loadDotenvIfAvailable();
146+
await uploadOpenapiJsonFiles();
147+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import test from 'node:test';
6+
7+
import {
8+
buildCdnUrl,
9+
buildOssFilePath,
10+
listOpenapiJsonFiles,
11+
uploadOpenapiJsonFiles,
12+
validateRequiredEnv
13+
} from './upload-openapi.mjs';
14+
15+
test('listOpenapiJsonFiles returns only direct JSON files sorted by name', () => {
16+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-upload-'));
17+
const apiReferenceDir = path.join(tempDir, 'api-reference');
18+
fs.mkdirSync(apiReferenceDir);
19+
fs.mkdirSync(path.join(apiReferenceDir, 'nested'));
20+
fs.writeFileSync(path.join(apiReferenceDir, 'rum.openapi.en.json'), '{}\n');
21+
fs.writeFileSync(path.join(apiReferenceDir, 'on-call.openapi.en.json'), '{}\n');
22+
fs.writeFileSync(path.join(apiReferenceDir, 'README.md'), '# docs\n');
23+
fs.writeFileSync(path.join(apiReferenceDir, 'nested', 'ignored.json'), '{}\n');
24+
25+
assert.deepEqual(listOpenapiJsonFiles(apiReferenceDir), [
26+
'on-call.openapi.en.json',
27+
'rum.openapi.en.json'
28+
]);
29+
});
30+
31+
test('buildOssFilePath keeps the environment prefix and adds api-reference', () => {
32+
assert.equal(
33+
buildOssFilePath('/docs', 'on-call.openapi.en.json'),
34+
'/docs/api-reference/on-call.openapi.en.json'
35+
);
36+
assert.equal(
37+
buildOssFilePath('/test/docs/', 'openapi.zh.json'),
38+
'/test/docs/api-reference/openapi.zh.json'
39+
);
40+
});
41+
42+
test('buildCdnUrl rewrites the OSS endpoint URL to the public CDN URL', () => {
43+
assert.equal(
44+
buildCdnUrl(
45+
'https://flashcat-docs.oss-cn-hangzhou.aliyuncs.com/docs/api-reference/openapi.en.json',
46+
'flashcat-docs.oss-cn-hangzhou.aliyuncs.com',
47+
'https://download.flashcat.cloud'
48+
),
49+
'https://download.flashcat.cloud/docs/api-reference/openapi.en.json'
50+
);
51+
});
52+
53+
test('validateRequiredEnv reports every missing upload credential', () => {
54+
assert.deepEqual(validateRequiredEnv({}), [
55+
'CDN_ACCESS_KEY',
56+
'CDN_SECRET_KEY',
57+
'CDN_BUCKET',
58+
'CDN_REGION',
59+
'CDN_ENDPOINT',
60+
'CDN_URL',
61+
'CDN_DIR'
62+
]);
63+
});
64+
65+
test('uploadOpenapiJsonFiles uploads every JSON file and refreshes each CDN URL', async () => {
66+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openapi-upload-'));
67+
const apiReferenceDir = path.join(tempDir, 'api-reference');
68+
fs.mkdirSync(apiReferenceDir);
69+
fs.writeFileSync(path.join(apiReferenceDir, 'openapi.en.json'), '{"openapi":"3.1.0"}\n');
70+
fs.writeFileSync(path.join(apiReferenceDir, 'openapi.zh.json'), '{"openapi":"3.1.0"}\n');
71+
fs.writeFileSync(path.join(apiReferenceDir, 'ignored.txt'), 'not json\n');
72+
73+
const uploaded = [];
74+
const refreshed = [];
75+
const env = {
76+
CDN_ACCESS_KEY: 'access-key',
77+
CDN_SECRET_KEY: 'secret-key',
78+
CDN_BUCKET: 'bucket',
79+
CDN_REGION: 'oss-cn-hangzhou',
80+
CDN_ENDPOINT: 'bucket.oss-cn-hangzhou.aliyuncs.com',
81+
CDN_URL: 'https://download.flashcat.cloud',
82+
CDN_DIR: '/docs'
83+
};
84+
const ossClient = {
85+
async put(ossFilePath, localFilePath, options) {
86+
uploaded.push({ ossFilePath, localFilePath, options });
87+
return { url: `https://bucket.oss-cn-hangzhou.aliyuncs.com${ossFilePath}` };
88+
}
89+
};
90+
const cdnRuntime = {
91+
CDN: {
92+
RefreshObjectCachesRequest: class RefreshObjectCachesRequest {}
93+
},
94+
client: {
95+
async refreshObjectCaches(request) {
96+
refreshed.push(request.objectPath);
97+
}
98+
}
99+
};
100+
101+
await uploadOpenapiJsonFiles({ apiReferenceDir, env, ossClient, cdnRuntime });
102+
103+
assert.deepEqual(
104+
uploaded.map((item) => item.ossFilePath),
105+
[
106+
'/docs/api-reference/openapi.en.json',
107+
'/docs/api-reference/openapi.zh.json'
108+
]
109+
);
110+
assert.deepEqual(
111+
refreshed,
112+
[
113+
'https://download.flashcat.cloud/docs/api-reference/openapi.en.json',
114+
'https://download.flashcat.cloud/docs/api-reference/openapi.zh.json'
115+
]
116+
);
117+
assert.equal(uploaded[0].options.headers['Content-Type'], 'application/json; charset=utf-8');
118+
});

0 commit comments

Comments
 (0)