feat(openapi): validate Dart SDK generation, make the spec self-sufficient (#1181)

Signed-off-by: Hampus Kraft <[email protected]>
This commit is contained in:
Hampus
2026-06-26 07:48:51 +02:00
committed by GitHub
parent 097e10d651
commit 6e87f6e3d1
7 changed files with 1248 additions and 1146 deletions
+59
View File
@@ -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"
+1 -2
View File
@@ -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"]
File diff suppressed because it is too large Load Diff
+74 -54
View File
@@ -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(),