mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(openapi): validate Dart SDK generation, make the spec self-sufficient (#1181)
Signed-off-by: Hampus Kraft <[email protected]>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: Dart SDK Validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check changed files
|
||||
id: changes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
changed_files="$(mktemp)"
|
||||
gh pr diff "$PULL_REQUEST_NUMBER" --repo "$GITHUB_REPOSITORY" --name-only > "$changed_files"
|
||||
if grep -Fxq 'fluxer_api/src/api/openapi/openapi.json' "$changed_files"; then
|
||||
echo "openapi=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "openapi=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out fluxer
|
||||
if: steps.changes.outputs.openapi == 'true'
|
||||
uses: actions/checkout@b9e0990d219a03df7633c93f6f005a8fecbcab22
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout Dart SDK
|
||||
if: steps.changes.outputs.openapi == 'true'
|
||||
uses: actions/checkout@b9e0990d219a03df7633c93f6f005a8fecbcab22
|
||||
with:
|
||||
repository: fluxerapp/dart_sdk
|
||||
ref: canary
|
||||
path: dart_sdk
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Dart
|
||||
if: steps.changes.outputs.openapi == 'true'
|
||||
uses: dart-lang/setup-dart@2bd11704788983a6a210731656d0042e2df8b2c1
|
||||
with:
|
||||
sdk: stable
|
||||
|
||||
- name: Validate Dart SDK generation
|
||||
if: steps.changes.outputs.openapi == 'true'
|
||||
working-directory: dart_sdk
|
||||
run: |
|
||||
chmod +x scripts/validate_upstream_spec.sh
|
||||
./scripts/validate_upstream_spec.sh "file://${GITHUB_WORKSPACE}/fluxer_api/src/api/openapi/openapi.json"
|
||||
@@ -14222,8 +14222,7 @@
|
||||
"me": {
|
||||
"description": "Whether the current user has reacted with this emoji",
|
||||
"nullable": true,
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["emoji", "count"]
|
||||
|
||||
+1101
-1086
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,71 @@ function makeNullableSchema(inner: OpenAPISchemaOrRef): OpenAPISchema {
|
||||
anyOf: [inner, {type: 'null'}],
|
||||
};
|
||||
}
|
||||
const discriminatedUnionBranchRegistry = new Map<string, OpenAPISchema>();
|
||||
|
||||
export function getRegisteredDiscriminatedUnionBranchSchemas(): Record<string, OpenAPISchema> {
|
||||
const result: Record<string, OpenAPISchema> = {};
|
||||
for (const [name, schema] of discriminatedUnionBranchRegistry) {
|
||||
result[name] = schema;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter((word) => word.length > 0)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
function discriminatedUnionBranchSuffix(
|
||||
branch: OpenAPISchema,
|
||||
discriminator: string,
|
||||
index: number,
|
||||
usedSuffixes: Set<string>,
|
||||
): string {
|
||||
let base = '';
|
||||
const discriminatorProp = branch.properties?.[discriminator];
|
||||
if (discriminatorProp && isOpenAPISchema(discriminatorProp)) {
|
||||
const enumNames = (discriminatorProp as {['x-enumNames']?: Array<string | null>})['x-enumNames'];
|
||||
const enumValues = discriminatorProp.enum;
|
||||
if (Array.isArray(enumNames) && typeof enumNames[0] === 'string') {
|
||||
base = toPascalCase(enumNames[0]);
|
||||
} else if (Array.isArray(enumValues) && enumValues.length === 1 && typeof enumValues[0] === 'string') {
|
||||
base = toPascalCase(enumValues[0]);
|
||||
}
|
||||
}
|
||||
if (base.length === 0) {
|
||||
base = `Variant${index}`;
|
||||
}
|
||||
let suffix = base;
|
||||
if (usedSuffixes.has(suffix)) {
|
||||
suffix = `${base}${index}`;
|
||||
}
|
||||
usedSuffixes.add(suffix);
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function registerDiscriminatedUnionBranches(
|
||||
unionName: string,
|
||||
discriminator: string,
|
||||
branches: Array<OpenAPISchemaOrRef>,
|
||||
): Array<OpenAPISchemaOrRef> {
|
||||
const usedSuffixes = new Set<string>();
|
||||
return branches.map((branch, index) => {
|
||||
if (!isOpenAPISchema(branch)) {
|
||||
return branch;
|
||||
}
|
||||
const suffix = discriminatedUnionBranchSuffix(branch, discriminator, index, usedSuffixes);
|
||||
const branchName = `${suffix}${unionName}`;
|
||||
if (!discriminatedUnionBranchRegistry.has(branchName)) {
|
||||
discriminatedUnionBranchRegistry.set(branchName, branch);
|
||||
}
|
||||
return {$ref: `#/components/schemas/${branchName}`};
|
||||
});
|
||||
}
|
||||
|
||||
export function zodToOpenAPISchema(schema: ZodTypeAny, depth = 0): OpenAPISchemaOrRef {
|
||||
if (depth > 20) {
|
||||
return {type: 'object'};
|
||||
@@ -480,61 +545,16 @@ export function zodToOpenAPISchema(schema: ZodTypeAny, depth = 0): OpenAPISchema
|
||||
return addDescription(literalSchema, schema);
|
||||
}
|
||||
}
|
||||
return addDescription(
|
||||
{
|
||||
oneOf: options.map((opt) => zodToOpenAPISchema(opt, depth + 1)),
|
||||
},
|
||||
schema,
|
||||
);
|
||||
}
|
||||
case 'ZodDiscriminatedUnion':
|
||||
case 'discriminatedUnion': {
|
||||
const def = getZodDefinition(schema);
|
||||
const discriminator = def.discriminator;
|
||||
let optionsArray: Array<ZodTypeAny>;
|
||||
let discriminatorValues: Array<unknown> = [];
|
||||
if (def.options instanceof Map) {
|
||||
discriminatorValues = Array.from(def.options.keys());
|
||||
optionsArray = Array.from(def.options.values());
|
||||
} else if (Array.isArray(def.options)) {
|
||||
optionsArray = def.options;
|
||||
} else {
|
||||
return {type: 'object'};
|
||||
const oneOfBranches = options.map((opt) => zodToOpenAPISchema(opt, depth + 1));
|
||||
const unionName = getSchemaName(schema);
|
||||
const unionDiscriminator = getZodDefinition(schema).discriminator;
|
||||
if (unionName && typeof unionDiscriminator === 'string') {
|
||||
return addDescription(
|
||||
{oneOf: registerDiscriminatedUnionBranches(unionName, unionDiscriminator, oneOfBranches)},
|
||||
schema,
|
||||
);
|
||||
}
|
||||
if (optionsArray.length === 0) {
|
||||
return {type: 'object'};
|
||||
}
|
||||
const schemas = optionsArray.map((opt) => zodToOpenAPISchema(opt, depth + 1));
|
||||
if (discriminator) {
|
||||
const result: OpenAPISchema = {
|
||||
oneOf: schemas,
|
||||
discriminator: {
|
||||
propertyName: discriminator,
|
||||
},
|
||||
};
|
||||
if (discriminatorValues.length > 0 && discriminatorValues.length === schemas.length) {
|
||||
const mapping: Record<string, string> = {};
|
||||
for (let i = 0; i < discriminatorValues.length; i++) {
|
||||
const value = discriminatorValues[i];
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const schemaObj = schemas[i];
|
||||
if (isOpenAPISchema(schemaObj) && schemaObj.properties?.[discriminator]) {
|
||||
mapping[String(value)] = `#/components/schemas/Option${i}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
result.discriminator!.mapping = mapping;
|
||||
}
|
||||
}
|
||||
return addDescription(result, schema);
|
||||
}
|
||||
return addDescription(
|
||||
{
|
||||
oneOf: schemas,
|
||||
},
|
||||
schema,
|
||||
);
|
||||
return addDescription({oneOf: oneOfBranches}, schema);
|
||||
}
|
||||
case 'ZodLiteral':
|
||||
case 'literal': {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import {getRegisteredBitflagSchemas, getRegisteredInt32EnumSchemas} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
|
||||
import {
|
||||
getRegisteredBitflagSchemas,
|
||||
getRegisteredDiscriminatedUnionBranchSchemas,
|
||||
getRegisteredInt32EnumSchemas,
|
||||
} from '@fluxer/openapi/src/converters/ZodToOpenAPI';
|
||||
import {OpenAPIGeneratorCatalog} from '@fluxer/openapi/src/generator/OpenAPIGeneratorCatalog';
|
||||
import {type LoadedSchema, loadSchemas} from '@fluxer/openapi/src/registry/SchemaLoader';
|
||||
import type {SchemaRegistry} from '@fluxer/openapi/src/registry/SchemaRegistry';
|
||||
@@ -43,6 +47,11 @@ export async function loadSchemasIntoRegistry(
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
}
|
||||
for (const [name, schema] of Object.entries(getRegisteredDiscriminatedUnionBranchSchemas())) {
|
||||
if (!schemaRegistry.has(name)) {
|
||||
schemaRegistry.register(name, schema);
|
||||
}
|
||||
}
|
||||
return {
|
||||
loadedSchemas,
|
||||
totalRegisteredSchemas: Object.keys(schemaRegistry.getAllSchemas()).length,
|
||||
|
||||
@@ -58,7 +58,7 @@ const ReactionEmojiResponse = z.object({
|
||||
export const MessageReactionResponse = z.object({
|
||||
emoji: ReactionEmojiResponse.describe('The emoji used for the reaction'),
|
||||
count: Int32Type.describe('The total number of times this reaction has been used'),
|
||||
me: z.literal(true).nullish().describe('Whether the current user has reacted with this emoji'),
|
||||
me: z.boolean().nullish().describe('Whether the current user has reacted with this emoji'),
|
||||
});
|
||||
|
||||
export type MessageReactionResponse = z.infer<typeof MessageReactionResponse>;
|
||||
|
||||
@@ -258,8 +258,8 @@ export type SelfServeRefundEligibilityResponse = z.infer<typeof SelfServeRefundE
|
||||
|
||||
const PremiumBillingState = z.object({
|
||||
stripe_customer_id: z.string().nullable(),
|
||||
current_subscription_price: CurrentSubscriptionPriceResponse,
|
||||
pending_subscription_change: PendingSubscriptionChangeResponse,
|
||||
current_subscription_price: CurrentSubscriptionPriceResponse.nullable(),
|
||||
pending_subscription_change: PendingSubscriptionChangeResponse.nullable(),
|
||||
subscription: PremiumBillingSubscriptionResponse.nullable(),
|
||||
invoices: z.array(PremiumBillingInvoiceResponse),
|
||||
invoices_has_more: z.boolean(),
|
||||
|
||||
Reference in New Issue
Block a user