openapi-ts / openapi-typescript

Generate TypeScript types from OpenAPI 3 specs
https://openapi-ts.dev
MIT License
5.97k stars 473 forks source link

Support for File Upload #1214

Open kollolsb opened 1 year ago

kollolsb commented 1 year ago

Description

In OpenAPI 3.0 we can describe the schema for files uploaded directly or as multipart:

      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                orderId:
                  type: integer
                userId:
                  type: integer
                file:
                  type: string
                  format: binary

The binary format indicates a File type object. However, the typescript interface generated by the library sets this property to string:

{
...
 /** Format: binary */
 file: string
}

Proposal

The type of the property should be set to File:

{
...
 /** Format: binary */
 file: File
}

NOTE: For reference the openapi-generator for typescript correctly sets the type to File but doesn't support OpenAPI 3.0

Checklist

Similar to #1123

drwpow commented 1 year ago

Currently, openapi-typescript ignores format because in most instances it’s subjective how you’d want it interpreted. For example, for { "type": "string", "format": "date" }, some may prefer it parsed as a Date while others prefer it left as a string because that’s what the JSON receives (there also can be a bit of ambiguity when it comes to a mutable Date and timezones as well that a readonly string may make clearer).

In this example, I believe string would be the correct default typing because that’s not the top-level response, right? Wouldn’t the object be initially parsed as JSON, which would make it impossible for File to happen?

That’s why the transform API exists—to give openapi-typescript additional insight into how your APIs may be transformed as they’re consumed—whether or not you parse Date, Blob, File, etc. Otherwise, this library may be at best overreaching telling you how to parse your schema, or at worst lying to you, giving incorrect types for things.

That said, if others prefer { "type": "string", "format": "file" } to be typed as File by default, with the default changeable with the transform API, I’d be open to that.

kollolsb commented 1 year ago

Thanks for pointing that out, I was able to use the transform API to switch the property type to File. I think what might help is mentioning this approach as a tip in the introduction section of the documentation; I had avoided the Node JS section because I thought it didn't apply for my use case. Also perhaps an inferType flag could be provided that covers the most common use cases like binary, date etc.

Thanks again for the quick response. I really like your library, will definitely recommend it to others, great job!

psychedelicious commented 1 year ago

I agree that there should be a prominent line in the docs noting that openapi-typescript tries to be conservative in its schema interpretation, and it's expected that you may need to use the Node.js API for some things. Calling out date and binary as common cases would also make sense.

Sawtaytoes commented 1 year ago

I spent the last 3 hours trying to figure this out. Part of it was the openapi-fetch not supporting the proper file Content-Type, but the generated schema wasn't helping me figure this out either.

I'm going with the bodySerializer + transformApi fix for now.

I'd love it if Blob was the default file type though. I'm glad this made its way into the docs, but I didn't see it and spent a lot of time trying to figure out what was wrong.

phaux commented 1 year ago

@drwpow

in most instances it’s subjective how you’d want it interpreted

Is it true tho? This is what the docs say about content type for a part in multipart body:

For 3.0.3:

When passing in multipart types, boundaries MAY be used to separate sections of the content being transferred — thus, the following default Content-Types are defined for multipart:

  • If the property is a primitive, or an array of primitive values, the default Content-Type is text/plain
  • If the property is complex, or an array of complex values, the default Content-Type is application/json
  • If the property is a type: string with format: binary or format: base64 (aka a file object), the default Content-Type is application/octet-stream

The Content-Type for encoding a specific property. Default value depends on the property type: for string with format being binaryapplication/octet-stream; for other primitive types – text/plain; for object - application/json; for array – the default is defined based on the inner type. The value can be a specific media type (e.g. application/json), a wildcard media type (e.g. image/*), or a comma-separated list of the two types.

or 3.1.0:

When passing in multipart types, boundaries MAY be used to separate sections of the content being transferred – thus, the following default Content-Types are defined for multipart:

  • If the property is a primitive, or an array of primitive values, the default Content-Type is text/plain
  • If the property is complex, or an array of complex values, the default Content-Type is application/json
  • If the property is a type: string with a contentEncoding, the default Content-Type is application/octet-stream

The Content-Type for encoding a specific property. Default value depends on the property type: for object - application/json; for array – the default is defined based on the inner type; for all other cases the default is application/octet-stream. The value can be a specific media type (e.g. application/json), a wildcard media type (e.g. image/*), or a comma-separated list of the two types.

I think because they insist on using JSON Schema for validating things that aren't JSON, like form-data, and JSON Schema doesn't have type "binary", they reused the string type (stupid) and made this magical option which must be handled specially. From what I understand, this is how I would need to request such API using vanilla JS (which I'm doing because not a single OpenAPI lib for JS can do it and just work):

const formData = new FormData();

// prop with primitive value (strings, numbers, booleans, I guess?)
formData.append("prop1", String(value)); // same as sending a text/plain blob

// prop with object
formData.append(
  "prop2", 
  new Blob([JSON.stringify(value)], { type: "application/json" }),
);

// for 3.0: prop with type "string", but format "binary"
// for 3.1: everything except object
formData.append(
  "prop3",
  new Blob([value], { type: "application/octet-stream" }),
);

// (and for arrays just call append many times for single property)

// send
fetch("https://example.com", { method: "POST", body: formData });

I think this lib should do similar thing.

  1. On generator side: Change the property's type to File|Blob|UInt8Array if either:
    • it's of type "string" with format "binary", or
    • the corresponding property in Encodings Object has contentType set to something else than text/plain.
  2. On the client library side: if the request is multipart and a property in passed object is of type:
    • string, number, boolean - append stringified value.
    • object - append JSON.stringified value and set content type to application/json.
    • File/Blob - append it as is.
    • Uint8Array - convert to blob with octet-stream type and append.
    • Array - iterate over the elements and do any of the above with each :arrow_up:
fredsensibill commented 9 months ago

For those that, like me, got stuck trying to make this work. Here's my complete solution for file uploads and downloads.

openapi-typescript version 6.x and OpenAPI 3.0.3

openapi-typescript types

generate-schema.mjs

import openapiTS from 'openapi-typescript';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const baseDir = path.dirname(fileURLToPath(import.meta.url));

const localPath = new URL(path.resolve(baseDir, '../apiSpec.json'), import.meta.url);
const output = await openapiTS(localPath, {
  transform(schemaObject, metadata) {
    if (schemaObject.format === 'binary') {
      if (metadata.path.endsWith('multipart/form-data')) {
        return schemaObject.nullable ? 'File | null' : 'File';
      }
      if (metadata.path.endsWith('application/octet-stream')) {
        return schemaObject.nullable ? 'Blob | null' : 'Blob';
      }
    }
    return undefined;
  }
});
await fs.promises.writeFile(path.resolve(baseDir, '../src/schema.d.ts'), output);

This sets the type to File if you are uploading something via multipart/form-data and Blob if you are downloading an application/octet-stream. This will not work if the schema is a reference to a separate component. For example,

THIS WILL NOT WORK:

"multipart/form-data": {
 "schema": {
  "$ref": "#/components/schemas/SomeFormSchema"
 }
}

THIS WORKS:

"multipart/form-data": {
 "schema": {
  "type": "object",
  "properties": {
    "file": {
      "type": "string",
      "format": "binary"
    }
  }
 }
}

openapi-fetch client

Uploading a file

async function upload(file: File) {
  await client.POST('/path/to/endpoint', {
    body: {
      file
    },
    bodySerializer: (body) => {
      const formData = new FormData();
      formData.set('file', body.file);
      return formData;
    }
  });
}

Downloading a file

async function download(): Blob | undefined {
  const { data } = client.GET('/path/to/endpoint', {
    parseAs: 'blob'
  });
  return data;
}
kaikun213 commented 7 months ago

Also stumbled up on this. I think it would be great to change the default options or at least give a CLI option to apply less conservative parsing?

waza-ari commented 7 months ago

Also keep in mind that NextJS server actions cannot take objects with Blob data in them yet. If you're using server actions and for some reason don't use FormData, you'll not be able to submit Blob data to server actions at this point, even with openapi-fetch supporting it.

Just in case you're struggling with this, it might be fixed in an upcoming release based on this PR.

armaneous commented 7 months ago

Not to add another, "me too" for the requested change, but I just spent an unhealthy amount of time trying to remedy this on my end. When trying to use the transform API, I kept getting errors because the schema didn't match the expected input for transformation, despite it being a valid schema otherwise. I started changing several parts of the schema, including adding fields that are not required according to the OpenAPI spec (e.g. variables parameter in a server object is expected, though not required), but then gave up because it was cascading into a larger change set that didn't make much sense.

I understand the resistance to imposing opinion. I think an optional flag when generating the schema viaa CLI could work just as well. It appears that a vast majority of use-cases for "format": "binary" are for Blob type.

joaopedrodcf commented 6 months ago

For those that, like me, got stuck trying to make this work. Here's my complete solution for file uploads and downloads.

openapi-typescript version 6.x and OpenAPI 3.0.3

openapi-typescript types

generate-schema.mjs

import openapiTS from 'openapi-typescript';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const baseDir = path.dirname(fileURLToPath(import.meta.url));

const localPath = new URL(path.resolve(baseDir, '../apiSpec.json'), import.meta.url);
const output = await openapiTS(localPath, {
  transform(schemaObject, metadata) {
    if (schemaObject.format === 'binary') {
      if (metadata.path.endsWith('multipart/form-data')) {
        return schemaObject.nullable ? 'File | null' : 'File';
      }
      if (metadata.path.endsWith('application/octet-stream')) {
        return schemaObject.nullable ? 'Blob | null' : 'Blob';
      }
    }
    return undefined;
  }
});
await fs.promises.writeFile(path.resolve(baseDir, '../src/schema.d.ts'), output);

This sets the type to File if you are uploading something via multipart/form-data and Blob if you are downloading an application/octet-stream. This will not work if the schema is a reference to a separate component. For example,

THIS WILL NOT WORK:

"multipart/form-data": {
 "schema": {
  "$ref": "#/components/schemas/SomeFormSchema"
 }
}

THIS WORKS:

"multipart/form-data": {
 "schema": {
  "type": "object",
  "properties": {
    "file": {
      "type": "string",
      "format": "binary"
    }
  }
 }
}

openapi-fetch client

Uploading a file

async function upload(file: File) {
  await client.POST('/path/to/endpoint', {
    body: {
      file
    },
    bodySerializer: (body) => {
      const formData = new FormData();
      formData.set('file', body.file);
      return formData;
    }
  });
}

Downloading a file

async function download(): Blob | undefined {
  const { data } = client.GET('/path/to/endpoint', {
    parseAs: 'blob'
  });
  return data;
}

Thank you so much 🥇

abencun-symphony commented 5 months ago

Based on what @fredsensibill did, I've implemented his solution using tsx in our package where we convert multiple schemas to TS at once. Put it in a file transformer.ts and just run it with tsx ./transformer.ts.

How the given example works: it grabs, for example, service1.yaml from the inputFolder and generates service1.ts in the outputFolder and does the same for all *.yaml files it fints in the inputFolder.

To be able to use top-level await make sure to use "type": "module", in your package.json and have the following in your tsconfig.json's compilerOptions:

 "compilerOptions": {
    "target": "ES2017",
    "module": "Preserve"
  },

Here's transformer.ts:

import fs from 'fs';
import openapiTS, { OpenAPITSOptions } from 'openapi-typescript';

const inputFolder = './specs';
const outputFolder = './schemas/specs';

const files = await fs.promises.readdir(inputFolder);
const schemaFilenames = files.filter(f => f.endsWith('.yaml')).map(f => f.split('.yaml')[0]);

// common options object for the AST
const options: OpenAPITSOptions = {
  transform(schemaObject, metadata) {
    if (schemaObject.format === 'binary') {
      if (metadata.path.endsWith('multipart/form-data')) {
        return schemaObject.nullable ? 'File | null' : 'File';
      }
      if (metadata.path.endsWith('application/octet-stream')) {
        return schemaObject.nullable ? 'Blob | null' : 'Blob';
      }
    }
    return undefined;
  }
}

// grab all the outputs
const promises: Promise<string>[] = schemaFilenames.map((schemaFilename) =>
  openapiTS(new URL(`${inputFolder}/${schemaFilename}.yaml`, import.meta.url), options)
);
const resolvedPromises = await Promise.all(promises);

// write to the filesystem
await fs.promises.mkdir(outputFolder, { recursive: true });
const fsPromises = schemaFilenames.map((schemaFilename, idx) =>
  fs.promises.writeFile(`${outputFolder}/${schemaFilename}.ts`, resolvedPromises[idx], { flag: 'w+' })
);
await Promise.all(fsPromises);
github-actions[bot] commented 2 months ago

This issue is stale because it has been open for 90 days with no activity. If there is no activity in the next 7 days, the issue will be closed.

github-actions[bot] commented 2 months ago

This issue was closed because it has been inactive for 7 days since being marked as stale. Please open a new issue if you believe you are encountering a related problem.

altearius commented 1 month ago

Version 7+ of openapi-typescript requires transform to return a TypeScript AST rather than a string. I have adjusted @fredsensibill's solution accordingly.

This is working for me, but I do not work with the TypeScript AST very often and parts of it look dodgy. Please consider I am just some dude in a comment section before using this for real.

import type { OpenAPITSOptions } from 'openapi-typescript';
import { factory } from 'typescript';

const options: OpenAPITSOptions = {
  transform({ format, nullable }, { path }) {
    if (format !== 'binary' || !path) {
      return;
    }

    const typeName = path.includes('multipart~1form-data')
      ? 'File'
      : path.includes('application~1octet-stream')
        ? 'Blob'
        : null;

    if (!typeName) {
      return;
    }

    const node = factory.createTypeReferenceNode(typeName);

    return nullable
      ? factory.createUnionTypeNode([
          node,
          factory.createTypeReferenceNode('null')
        ])
      : node;
  }
};
waza-ari commented 1 month ago

FWIW, also sharing my solution, same disclaimer as above (just a random comment guy). To be called directly with API spec as argument:


import openapiTS, { astToString }  from 'openapi-typescript';
import fs from "node:fs";
import ts from "typescript";
if (process.argv.length === 2) {
  console.error('Expected at least one argument!');
  process.exit(1);
}
const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Blob"));
const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull());
const ast = await openapiTS(new URL(process.argv[2]), {
  transform(schemaObject, metadata) {
    if (schemaObject.format === 'binary') {
      return schemaObject.nullable ? ts.factory.createUnionTypeNode([BLOB, NULL]) : BLOB;
    }
  }
});
const output = astToString(ast);
fs.writeFileSync('./src/lib/api/v1.d.ts', output);
psychedelicious commented 1 month ago

The docs actually have an up-to-date example for the new AST-based API: https://openapi-ts.dev/node#example-blob-types