Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: narrow type based on status code #1970

Open
wants to merge 18 commits into
base: main
Choose a base branch
from

Conversation

DjordyKoert
Copy link
Contributor

@DjordyKoert DjordyKoert commented Oct 25, 2024

Changes

The various client methods (GET, POST, PUT e.t.c.) now have an additional status property.

This property allows users to narrow the type of their data/error based on their documented response status codes.

How to Review

How can a reviewer review your changes? What should be kept in mind for this review?

Checklist

  • Unit tests updated
  • docs/ updated (if necessary)
  • pnpm run update:examples run (only applicable for openapi-typescript)

Copy link

changeset-bot bot commented Oct 25, 2024

🦋 Changeset detected

Latest commit: 7aa73ca

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
openapi-typescript-helpers Patch
openapi-react-query Patch
openapi-fetch Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@drwpow drwpow added the openapi-fetch Relevant to the openapi-fetch library label Oct 26, 2024
@drwpow
Copy link
Contributor

drwpow commented Nov 27, 2024

I LOVE this! Please let us know if you need any help with the tests or the current direction. This would be amazing to land

@DjordyKoert DjordyKoert force-pushed the type-narrow-on-status-code branch from ff0685c to c4f66ce Compare November 28, 2024 12:49
@DjordyKoert DjordyKoert marked this pull request as ready for review November 28, 2024 13:26
@DjordyKoert DjordyKoert requested a review from a team as a code owner November 28, 2024 13:26
@DjordyKoert DjordyKoert requested a review from gzm0 November 28, 2024 13:26
@DjordyKoert
Copy link
Contributor Author

@drwpow I think this is ready for a review now :)

@DjordyKoert DjordyKoert force-pushed the type-narrow-on-status-code branch from 7cff619 to 21c7f26 Compare November 28, 2024 13:32
Copy link
Contributor

@drwpow drwpow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking great! But the FIXME in the type tests for if (result.data) vs if (result.error) could be a breaking change, and could be a type error. The status narrowing seems to work brilliantly, but we’ll need to make sure existing users don’t introduce bugs with this new feature by making sure the current type checks work.

If we can find a way to not have the FIXME in the type tests, I think we’ve got it.

.changeset/rare-grapes-explode.md Outdated Show resolved Hide resolved
.changeset/rare-grapes-explode.md Outdated Show resolved Hide resolved
.changeset/rare-grapes-explode.md Outdated Show resolved Hide resolved
@@ -158,17 +161,18 @@ The `POST()` request required a `body` object that provided all necessary [reque

### Response

All methods return an object with **data**, **error**, and **response**.
All methods return an object with **data**, **error**, **status** and **response**.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great docs updates! Thank you

@@ -20,22 +20,38 @@ describe("response", () => {
// 2. assert data is not undefined inside condition block
if (result.data) {
assertType<NonNullable<Resource[]>>(result.data);
assertType<undefined>(result.error);
// @ts-expect-error FIXME: This is a limitation within Typescript
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love these tests, and this is a great start! But I fear that we’ll have to fix this before merging (it’ll be much more difficult to fix in a followup, when other work has built off the inference). I don’t really care if the type is undefined or never—that’s a detail—but TS should be able to infer the other property otherwise there could be issues!


if (result.status === 200) {
assertType<NonNullable<Resource[]>>(result.data);
assertType<never>(result.error);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are good tests! I would want to see the other common codes tested here, too—201, and 400405, just for completeness (and if they’re not in the schema, we should add them!)

}

// @ts-expect-error 204 is not defined in the schema
if (result.status === 204) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a REALLY good test I want to keep—testing what happens when a status code is impossible for a given endpoint! But we should test that 204 works somewhere, even if it’s a different test / different endpoint (doesn’t matter where, as long as it’s somewhere, but soft recommendation to just add 204 here since we have all the other checks, and test for the “missing” case in its own test).

Copy link
Contributor

@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super cool!

I've left some comments mostly regarding http status wildcard precedence.

response: Response;
status: OpenApiStatusToHttpStatus<S, keyof ResponseObjectMap<T>>;
data: S extends OkStatus ? ParseAsResponse<GetResponseContent<ResponseObjectMap<T>, Media, S>, Options> : never;
error: S extends ErrorStatus ? GetResponseContent<ResponseObjectMap<T>, Media, S> : never;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super nice! I like how it removes "specialness" from data versus error.

IIUC, at this point, the only difference between data and error is parseAs. (totally unrelated to this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already got a PR semi-ready about that #1986 :)

* 'default' returns every not explicitly defined status code
* '2XX' returns all 2XX status codes
* '4XX' returns all 4XX status codes
* '5XX' returns all 5XX status codes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we remove the explicitly defined status codes from the wildcards? From the OpenAPI Spec (https://swagger.io/specification/#responses-object)

If a response is defined using an explicit code, the explicit code definition takes precedence over the range definition for that code.

So for example, if we have: "2xx" -> A and "204" -> B, with different, "204" should resolve to B and I think currently it would resolve to A | B.

? Exclude<Exclude<OkStatus | ErrorStatus, string>, AllStatuses>
: Status extends "2XX"
? Exclude<OkStatus, string>
: Status extends "4XX" | "5XX"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might help readability if you split OkStatus / ErrorStatus into numerics / wildcards. For example:

export type ErrorStatus = NumericErrorStatus | WildcardErrorStatus | "default";
type NumericErrorStatus = 500 | ...
type WildcardErrorStatus = '5XX' | '4XX'; 

export type OpenApiStatusToHttpStatus<Status, AllStatuses> = Status extends number
  ? Status
  : Status extends "default"
    ? Exclude<NumericOKStatus | NumericErrorStatus, AllStatuses>
    : Status extends WildcardOKStatus
      ? NumericOKStatus
      : Status extends WildcardErrorStatus
     ? NumericErrorStatus
     : never

(totally optional)

@@ -280,4 +286,179 @@ describe("types", () => {
assertType<Response>({ error: "default application/json" });
});
});

describe("OpenApiStatusToHttpStatus", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is super nice to see unit tests for this helper!

I was wondering: IIUC the second argument (AllStatuses) comes directly from the response object keys. In this case, it could contain wildcards (e.g. "2XX"). Would it make sense to add tests for these as well? (I have tried pointing out places below where I think this might affect the tests).

@drwpow self note to maintainers: we should probably set up the infrastructure for openapi-typescript-helpers to have tests :)

});

test("returns 200 likes response", () => {
type Status = OpenApiStatusToHttpStatus<"2XX", 200 | 204 | 206 | 404 | 500 | "default">;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels to me like this test case is "degenerate" at least if I understand the usage in this PR correctly:

I would expect the Status parameter to always be a subtype of AllStatuses (which in this case it isn't).

For this test case specifically, I would expect that the explicitly / numerically defined status codes are not part of the end result (precedence, as I pointed out in the doc comment of OpenApiStatusToHttpStatus).

});

test("returns default response", () => {
type Status = OpenApiStatusToHttpStatus<"default", 200 | 204 | 206 | 404 | 500 | "default">;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense add a test case where there also is a wildcard (e.g. "2XX") in the second argument?

* '2XX' returns all 2XX status codes
* '4XX' returns all 4XX status codes
* '5XX' returns all 5XX status codes
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: IIUC this implementation has the limitation that status codes unknown to openapi-fetch have to be explicitly defined in the OpenAPI spec so they can be typed on the client side.

For example, "2XX" in the OpenAPI spec will not match 209:

if (status === 209) {
  // `data` will be never
}

IMO this is a reasonable limitation / design decision.

Relevant excerpt from RFC 7231

HTTP status codes are extensible. HTTP clients are not required to
understand the meaning of all registered status codes, though such
understanding is obviously desirable. However, a client MUST
understand the class of any status code, as indicated by the first
digit, and treat an unrecognized status code as being equivalent to
the x00 status code of that class, with the exception that a
recipient MUST NOT cache a response with an unrecognized status code.

For example, if an unrecognized status code of 471 is received by a
client, the client can assume that there was something wrong with its
request and treat the response as if it had received a 400 (Bad
Request) status code. The response message will usually contain a
representation that explains the status.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
openapi-fetch Relevant to the openapi-fetch library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants